merge the sidecar isolation work
Brings in the five per-sidecar branches that were integrated on `sidecars`: the agent
split out of officer's process tree (officer-anthropic-proxy + officer-agent as PM2
peers), turn output translated and committed by the sidecar that produced it for both
claude and opencode, the pty reconnect fix and its move under sidecar/, the vnc
password read and desktop reattach, the cliamp audio pipeline moved into the music
sidecar, and the email folder-filter SQL injection.
Verified live before merging: all ten PM2 entrypoints start clean, the agent resolves
the owner from the database and the proxy secret from disk, and a real turn round-trips
through the new path — the sidecar writes chat_session_events itself and `prevSeq`
chains correctly across messages.
One conflict, docs/navigation-audit.md (add/add): the copy on `sidecars` was an earlier
snapshot swept in by 62dc4c1, and master's is the same document with H1-H3 marked done.
Took master's wholesale — nothing was lost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,9 @@ export const DesktopView = ({ className, style }: DesktopViewProps) => {
|
||||
const rfbRef = useRef<RFBInstance | null>(null);
|
||||
const isMounted = useMounted();
|
||||
const client = useClient();
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting');
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error'>(
|
||||
'connecting',
|
||||
);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,20 +52,64 @@ export const DesktopView = ({ className, style }: DesktopViewProps) => {
|
||||
if (!container) return;
|
||||
|
||||
let disposed = false;
|
||||
let attempts = 0;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Tearing an RFB down makes it fire its own `disconnect`, and a bad password fires `securityfailure`
|
||||
// and then `disconnect` too. Both would otherwise be read as "officer went away, reattach".
|
||||
let generation = 0;
|
||||
let fatal = false;
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const RETRY_DELAYS = [1000, 2000, 3000, 5000, 5000];
|
||||
|
||||
const detach = () => {
|
||||
const rfb = rfbRef.current;
|
||||
rfbRef.current = null;
|
||||
generation++;
|
||||
if (!rfb) return;
|
||||
try {
|
||||
rfb.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
// x11vnc mirrors :0 with `-forever`, so the desktop itself outlives this socket — losing it means
|
||||
// officer restarted under us, not that the session ended. Reattach instead of parking on
|
||||
// "Disconnected" until someone reopens the panel.
|
||||
const retry = (reason: string) => {
|
||||
if (disposed || fatal || retryTimer) return;
|
||||
detach();
|
||||
if (attempts >= MAX_ATTEMPTS) {
|
||||
setStatus('disconnected');
|
||||
setErrorMsg(reason);
|
||||
return;
|
||||
}
|
||||
const delay = RETRY_DELAYS[attempts] ?? 5000;
|
||||
attempts++;
|
||||
setStatus('reconnecting');
|
||||
setErrorMsg(`${reason} — reconnecting (${attempts}/${MAX_ATTEMPTS})...`);
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
void connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
const gen = ++generation;
|
||||
const isCurrent = () => !disposed && gen === generation;
|
||||
let password = '';
|
||||
try {
|
||||
const res = await client.get<{ password: string }>('/desktop/vnc-password');
|
||||
password = res.password;
|
||||
} catch {
|
||||
if (disposed) return;
|
||||
setStatus('error');
|
||||
setErrorMsg('Failed to fetch VNC password');
|
||||
if (!isCurrent()) return;
|
||||
// Officer being down is the common case here, and it comes back — so this is a retry, not a
|
||||
// dead end. A sidecar that is genuinely missing still ends up at "Disconnected" after five.
|
||||
retry('Failed to fetch VNC password');
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
if (!isCurrent()) return;
|
||||
|
||||
let RFB: Awaited<ReturnType<typeof loadRFB>>['default'];
|
||||
try {
|
||||
@@ -71,13 +117,14 @@ export const DesktopView = ({ className, style }: DesktopViewProps) => {
|
||||
RFB = mod.default;
|
||||
} catch (err) {
|
||||
console.error('[desktop] Failed to load noVNC:', err);
|
||||
if (disposed) return;
|
||||
if (!isCurrent()) return;
|
||||
fatal = true;
|
||||
setStatus('error');
|
||||
setErrorMsg('Failed to load noVNC library');
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
if (!isCurrent()) return;
|
||||
|
||||
const wsUrl = buildWsUrl();
|
||||
const rfb = new RFB(container, wsUrl, {
|
||||
@@ -90,15 +137,18 @@ export const DesktopView = ({ className, style }: DesktopViewProps) => {
|
||||
rfbRef.current = rfb;
|
||||
|
||||
rfb.addEventListener('connect', () => {
|
||||
if (!disposed) setStatus('connected');
|
||||
if (!isCurrent()) return;
|
||||
attempts = 0;
|
||||
setErrorMsg('');
|
||||
setStatus('connected');
|
||||
});
|
||||
|
||||
rfb.addEventListener('disconnect', (ev: CustomEvent) => {
|
||||
if (disposed) return;
|
||||
setStatus('disconnected');
|
||||
if (!ev.detail.clean) {
|
||||
setErrorMsg('Connection lost');
|
||||
}
|
||||
// Every disconnect we did not ask for is worth retrying, clean or not: officer closing its side
|
||||
// tidily during a restart still reports `clean`, and the desktop behind it is still there.
|
||||
rfb.addEventListener('disconnect', () => {
|
||||
if (!isCurrent()) return;
|
||||
rfbRef.current = null;
|
||||
retry('Connection lost');
|
||||
});
|
||||
|
||||
rfb.addEventListener('credentialsrequired', () => {
|
||||
@@ -106,25 +156,30 @@ export const DesktopView = ({ className, style }: DesktopViewProps) => {
|
||||
});
|
||||
|
||||
rfb.addEventListener('securityfailure', (ev: CustomEvent) => {
|
||||
if (!disposed) {
|
||||
setStatus('error');
|
||||
setErrorMsg(ev.detail.reason || 'Security failure');
|
||||
}
|
||||
if (!isCurrent()) return;
|
||||
fatal = true;
|
||||
setStatus('error');
|
||||
setErrorMsg(ev.detail.reason || 'Security failure');
|
||||
});
|
||||
};
|
||||
|
||||
// Coming back to the tab after the retries ran out should try once more rather than stay dead.
|
||||
const handleVisibility = () => {
|
||||
if (disposed || fatal || document.visibilityState !== 'visible') return;
|
||||
if (rfbRef.current || retryTimer) return;
|
||||
attempts = 0;
|
||||
setStatus('connecting');
|
||||
void connect();
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (rfbRef.current) {
|
||||
try {
|
||||
rfbRef.current.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
rfbRef.current = null;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
detach();
|
||||
};
|
||||
}, [isMounted, client]);
|
||||
|
||||
@@ -138,7 +193,7 @@ export const DesktopView = ({ className, style }: DesktopViewProps) => {
|
||||
Connecting to desktop...
|
||||
</div>
|
||||
)}
|
||||
{(status === 'disconnected' || status === 'error') && (
|
||||
{(status === 'reconnecting' || status === 'disconnected' || status === 'error') && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{errorMsg || 'Disconnected from desktop'}
|
||||
</div>
|
||||
|
||||
@@ -218,6 +218,14 @@ export const TerminalView = ({
|
||||
onCommandDoneRef.current(exitCode, output);
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'replay') {
|
||||
// Scrollback for a session we are re-attaching to. On a page load this terminal is empty and
|
||||
// the reset is a no-op; after an officer restart it still holds what it had before the socket
|
||||
// dropped, and the replay overlaps it — so rebuild from the sidecar's copy rather than append
|
||||
// a second one. Deliberately outside the `output` branch: replay must not re-trigger the
|
||||
// command/initial-input logic above.
|
||||
term.reset();
|
||||
term.write(msg.data);
|
||||
} else if (msg.type === 'exit') {
|
||||
processExited = true;
|
||||
term.write('\r\n[Process exited]\r\n');
|
||||
|
||||
@@ -141,6 +141,20 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
||||
function handleMessage(data: unknown) {
|
||||
const msg = data as ServerMessage;
|
||||
|
||||
// Continuity check, before the cursor moves. The writer stamps every durable message with the cursor
|
||||
// of the one before it in the same session, so a mismatch against what we last saw means something is
|
||||
// missing — pruned by retention, or a write that failed. Surface it: a conversation that silently
|
||||
// skips a tool call or half an answer reads as the assistant having done something inexplicable.
|
||||
// Only checked once we actually hold a cursor; opening a session from history starts mid-chain by
|
||||
// design (events are swept after 7 days, the transcript itself is not).
|
||||
const prevSeq = (data as { prevSeq?: number }).prevSeq;
|
||||
if (typeof prevSeq === 'number' && cursorRef.current > 0 && prevSeq !== cursorRef.current) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'error', text: '⚠️ Some output could not be recovered — part of this conversation is missing above.' },
|
||||
]);
|
||||
}
|
||||
|
||||
// Advance the resume cursor for any durable (seq-carrying) event.
|
||||
const seq = (data as { seq?: number }).seq;
|
||||
if (typeof seq === 'number' && seq > cursorRef.current) cursorRef.current = seq;
|
||||
|
||||
Reference in New Issue
Block a user