From 7595392245db9ef98dea5d447b7c60d5d38a6e4e Mon Sep 17 00:00:00 2001 From: barakmen Date: Wed, 29 Apr 2026 17:05:38 +0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(adr-18):=20phases=208-10=20=E2=80=94?= =?UTF-8?q?=20ptyId=20persistence,=20UI-only=20update,=20daemon-mode=20def?= =?UTF-8?q?ault-on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 8 (frontend): TerminalPane stores ptyId via onPtyReady prop; WS reconnect logic retries up to 8 times on daemon disconnect; stale ptyId cleared automatically when server returns "not found". app.jsx wires updatePaneSpawn helper + handlePtyReady callback so spawn trees carry live ptyId. UpdateBanner forks apply path for daemon mode (apply-ui-only + close_for_update). DaemonDisconnectedBanner polls /api/health every 5s and surfaces a reconnect button. Phase 9 (backend): _PywebviewApi exposes close_for_update to JS via pywebview js_api. updater.DAEMON_MODE flag routes staged path to state_dir in daemon mode and enables apply_ui_only_update(). /api/update/apply-ui-only endpoint wired to real implementation. daemon/__main__.py sets DAEMON_MODE=True before uvicorn starts. pyinstaller-daemon.spec builds AgentManager-Daemon.exe (no webview, console=True). CI workflow builds + renames daemon exe and publishes it as a release asset. Phase 10: AGENTMANAGER_DAEMON default flipped to "1" (opt-out via =0). Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 10 + backend/app.py | 16 +- backend/cli.py | 13 +- backend/frontend/app.jsx | 97 ++++++++- backend/frontend/terminal-pane.jsx | 328 ++++++++++++++++------------- backend/updater.py | 54 ++++- daemon/__main__.py | 4 + pyinstaller-daemon.spec | 75 +++++++ 8 files changed, 435 insertions(+), 162 deletions(-) create mode 100644 pyinstaller-daemon.spec diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0e2c90..c66637d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,6 +108,14 @@ jobs: cp "dist/AgentManager-${VER}-windows-x64.exe" \ "dist/claude-sessions-viewer-${VER}-windows-x64.exe" ls -la dist/ + - name: Build daemon exe (PyInstaller) + run: pyinstaller pyinstaller-daemon.spec --noconfirm --clean + - name: Rename daemon exe with version + shell: bash + run: | + VER="${{ needs.build.outputs.version }}" + mv "dist/AgentManager-Daemon.exe" "dist/AgentManager-Daemon-${VER}-windows-x64.exe" + ls -la dist/AgentManager-Daemon-*.exe - name: Smoke-test exe boots shell: bash run: | @@ -139,6 +147,7 @@ jobs: name: windows-exe path: | dist/AgentManager-*-windows-x64.exe + dist/AgentManager-Daemon-*-windows-x64.exe dist/claude-sessions-viewer-*-windows-x64.exe # ADR-18 / Task #48: build a real Windows installer (Inno Setup) @@ -310,5 +319,6 @@ jobs: dist/claude_sessions_viewer-*.tar.gz \ dist/claude-sessions-viewer-*-windows.zip \ dist/AgentManager-*-windows-x64.exe \ + dist/AgentManager-Daemon-*-windows-x64.exe \ dist/claude-sessions-viewer-*-windows-x64.exe \ dist/AgentManager-*-setup.exe diff --git a/backend/app.py b/backend/app.py index de74758..3bb045d 100644 --- a/backend/app.py +++ b/backend/app.py @@ -729,19 +729,11 @@ def _suicide() -> None: def update_apply_ui_only() -> dict[str, Any]: """Swap the UI exe only; leave the daemon running (Phase 9 payoff). - Today: stub that returns 501 with a machine-parseable reason code - so the Phase-1 e2e test can assert the contract is in place. + The daemon keeps running and PTY sessions survive. The UI window is + expected to close shortly after this returns so the swap script can + rename the locked exe. """ - raise HTTPException( - status_code=501, - detail={ - "code": "DAEMON_NOT_SPLIT", - "message": ( - "UI-only updates require the two-binary ship (Phase 9). " - "Current builds package UI+daemon in one exe; use /api/update/apply." - ), - }, - ) + return dict(updater.apply_ui_only_update()) @app.post("/api/update/apply-daemon") diff --git a/backend/cli.py b/backend/cli.py index 7a7e8a9..c3e8447 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -224,7 +224,7 @@ def main(argv: list[str] | None = None) -> int: # reads it and attaches it as Authorization on subsequent requests). # Legacy (non-daemon) mode is preserved by default so existing users are # unaffected until we flip the default in v1.3.0. - if os.environ.get("AGENTMANAGER_DAEMON") == "1": + if os.environ.get("AGENTMANAGER_DAEMON", "1") != "0": rc = _launch_daemon_mode(webview) if rc is not None: return rc @@ -258,6 +258,16 @@ def main(argv: list[str] | None = None) -> int: return 0 +class _PywebviewApi: + def close_for_update(self) -> None: + try: + import webview as _wv + + _wv.destroy() + except Exception: + pass + + def _launch_daemon_mode(webview_mod: Any) -> int | None: """Opt-in daemon-split launch path (ADR-18 / Task #42). @@ -308,6 +318,7 @@ def _launch_daemon_mode(webview_mod: Any) -> int | None: height=900, resizable=True, confirm_close=False, + js_api=_PywebviewApi(), ) webview_mod.start() return 0 diff --git a/backend/frontend/app.jsx b/backend/frontend/app.jsx index 447ce27..42f8df8 100644 --- a/backend/frontend/app.jsx +++ b/backend/frontend/app.jsx @@ -18,6 +18,45 @@ const DEFAULT_TWEAKS = /*EDITMODE-BEGIN*/{ "liveOn": true }/*EDITMODE-END*/; +function DaemonDisconnectedBanner() { + const [disconnected, setDisconnected] = React.useState(false); + React.useEffect(() => { + if (!window._daemonToken) return; + const check = async () => { + try { + const r = await fetch('/api/health', { signal: AbortSignal.timeout(2000) }); + setDisconnected(!r.ok); + } catch { + setDisconnected(true); + } + }; + const id = setInterval(check, 5000); + check(); + return () => clearInterval(id); + }, []); + if (!disconnected) return null; + return ( +
+ ⚠ Daemon disconnected — terminal sessions may be unavailable + +
+ ); +} + function WindowChrome({ children, tweaks, onToggleTweaks, selectedCount, activeCount }) { // Fetch the real version from /api/status on first paint. The hardcoded // "v0.4.2" that used to live below was silently stale across every 0.5+ @@ -120,6 +159,7 @@ function WindowChrome({ children, tweaks, onToggleTweaks, selectedCount, activeC + {/* Body: two-pane */}
@@ -134,6 +174,7 @@ function UpdateBanner({ accent }) { // Phases — idle (hidden) | available | downloading | staged | applying | error. // Polls on mount + every 5 min (cheap: just reads in-memory state, no HTTP // out to github). Phase transitions drive button copy + progress bar. + const daemonMode = !!window._daemonToken; const [st, setSt] = React.useState(null); const [busy, setBusy] = React.useState(null); // 'download' | 'apply' | null const [localErr, setLocalErr] = React.useState(null); @@ -194,12 +235,23 @@ function UpdateBanner({ accent }) { if (!confirm('Restart the app now to apply the update?')) return; setBusy('apply'); setLocalErr(null); try { - const r = await fetch('/api/update/apply', { method: 'POST' }); - const j = await r.json(); - if (!j.ok) { setLocalErr(j.message || 'apply failed'); setBusy(null); return; } - // The server will exit ~800ms after responding. Show a full-screen - // curtain so the user sees *something* while the swap script runs. - setTimeout(() => window.location.reload(), 6000); + if (daemonMode) { + const r = await fetch('/api/update/apply-ui-only', { method: 'POST' }); + const j = await r.json(); + if (!j.ok) { setLocalErr(j.message || 'apply failed'); setBusy(null); return; } + // In daemon mode, close the webview window to let the swap script run. + // The daemon stays alive; PTY sessions survive. Fallback reload in case + // the pywebview API isn't available (e.g. browser dev mode). + window.pywebview?.api?.close_for_update?.(); + setTimeout(() => window.location.reload(), 1500); + } else { + const r = await fetch('/api/update/apply', { method: 'POST' }); + const j = await r.json(); + if (!j.ok) { setLocalErr(j.message || 'apply failed'); setBusy(null); return; } + // The server will exit ~800ms after responding. Show a full-screen + // curtain so the user sees *something* while the swap script runs. + setTimeout(() => window.location.reload(), 6000); + } } catch (e) { setLocalErr(String(e)); setBusy(null); @@ -635,6 +687,22 @@ function firstSessionIdInTree(tree) { return null; } +function updatePaneSpawn(tree, paneId, ptyId) { + if (!tree) return tree; + if ((tree.kind === 'pane' || tree.spawn) && tree.id === paneId) { + const spawn = ptyId + ? { ...tree.spawn, ptyId } + : Object.fromEntries(Object.entries(tree.spawn || {}).filter(([k]) => k !== 'ptyId')); + return { ...tree, spawn }; + } + if (tree.kind === 'split' && Array.isArray(tree.children)) { + const next = tree.children.map((c) => updatePaneSpawn(c, paneId, ptyId)); + if (next.every((c, i) => c === tree.children[i])) return tree; + return { ...tree, children: next }; + } + return tree; +} + function RightPane({ selected, accent, onOpen, onActiveSessionChange }) { // terminals[].tree is the tile tree for that tab (built via // window.splits.makePane / splitNode / closeNode). @@ -653,6 +721,16 @@ function RightPane({ selected, accent, onOpen, onActiveSessionChange }) { const [focusedPaneId, setFocusedPaneId] = React.useState(null); const hydratedRef = React.useRef(false); + const handlePtyReady = React.useCallback((tabId, paneId, ptyId) => { + setTerminals((prev) => + prev.map((tab) => + tab.id !== tabId + ? tab + : { ...tab, tree: updatePaneSpawn(tab.tree, paneId, ptyId) }, + ), + ); + }, []); + // Hydrate from the server-persisted snapshot on first mount. PTY // processes themselves can't survive a restart — but the tile tree, // tab labels, spawn config, active tab, and focused pane all can. @@ -963,7 +1041,12 @@ function RightPane({ selected, accent, onOpen, onActiveSessionChange }) { onFocus={setFocusedPaneId} onUpdateTree={(updater) => updateActiveTree(updater)}/> {panes.map((p) => ( - + handlePtyReady(t.id, p.id, ptyId)} + /> ))}
); diff --git a/backend/frontend/terminal-pane.jsx b/backend/frontend/terminal-pane.jsx index 921d68e..742b499 100644 --- a/backend/frontend/terminal-pane.jsx +++ b/backend/frontend/terminal-pane.jsx @@ -100,7 +100,7 @@ async function typeIntoPty(send, text) { // + render its own prompt (~3s). 8s leaves margin. const RESTART_PING_DELAY_MS = 8000; -function TerminalPane({ spawn, onExit, onReady, className, paneId }) { +function TerminalPane({ spawn, onExit, onReady, onPtyReady, className, paneId }) { // `spawn` — object passed as the first WS frame. Shape: // { cmd: ["cmd.exe"] } // ad-hoc shell // { provider: "claude-code", sessionId: "" } // resume @@ -118,9 +118,18 @@ function TerminalPane({ spawn, onExit, onReady, className, paneId }) { const wsRef = React.useRef(null); const fitRef = React.useRef(null); const disposedRef = React.useRef(false); - const [status, setStatus] = React.useState('connecting'); // connecting|ready|exited|error + const reconnectCountRef = React.useRef(0); + const statusRef = React.useRef('connecting'); + const ptyIdNotFoundRef = React.useRef(false); + const [status, setStatusState] = React.useState('connecting'); // connecting|ready|exited|error const [error, setError] = React.useState(null); + // Keep statusRef in sync with state on every update + const setStatus = React.useCallback((s) => { + statusRef.current = s; + setStatusState(s); + }, []); + // React's "original parent" for our wrapper — whatever DOM element // our wrapper was inserted into on first render (the tab div, as // part of app.jsx's flat panes.map). React will later call @@ -174,6 +183,8 @@ function TerminalPane({ spawn, onExit, onReady, className, paneId }) { React.useEffect(() => { disposedRef.current = false; + reconnectCountRef.current = 0; + ptyIdNotFoundRef.current = false; // Guard against SSR / early mount where globals aren't loaded yet. if (typeof window === 'undefined' || !window.Terminal) { console.warn('[pty] xterm.js not loaded yet'); @@ -214,13 +225,11 @@ function TerminalPane({ spawn, onExit, onReady, className, paneId }) { try { fit.fit(); } catch (e) { console.warn('[pty] initial fit failed', e); } } - const ws = new WebSocket(ptyWsUrl()); - wsRef.current = ws; - + // Use wsRef so send always targets the current socket after reconnects const send = (obj) => { if (disposedRef.current) return; - if (ws.readyState !== WebSocket.OPEN) return; - ws.send(JSON.stringify(obj)); + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + wsRef.current.send(JSON.stringify(obj)); }; // Ctrl+C with a selection → copy to clipboard (don't send SIGINT). @@ -258,146 +267,180 @@ function TerminalPane({ spawn, onExit, onReady, className, paneId }) { return true; }); - ws.addEventListener('open', () => { - const cols = term.cols; - const rows = term.rows; - const first = { type: 'spawn', cols, rows, ...spawn }; - console.log('[pty] → spawn', first); - send(first); - }); - - ws.addEventListener('message', (ev) => { - if (disposedRef.current) return; - let msg; - try { msg = JSON.parse(ev.data); } - catch { console.warn('[pty] non-JSON frame', ev.data); return; } - console.log('[pty] ← ', msg.type, msg); - switch (msg.type) { - case 'ready': { - setStatus('ready'); - onReady && onReady(msg.id); - - // v1.1.0 (#47): shell-wrap session tabs. If this pane was - // opened via "In viewer" on a session, it spawned a shell - // (cmd.exe) in the session's cwd — NOT `claude --resume` - // directly. Here we type the resume command into the shell - // so claude takes over the PTY. When the user later runs - // `/exit`, claude quits and the shell prompt returns — the - // tab stays alive and reusable. - const autoResume = spawn?._autoResume; - if ( - autoResume?.sessionId - && msg.id - && !window._autoResumeTyped.has(msg.id) - ) { - window._autoResumeTyped.add(msg.id); - // Wait ~1.2s for the shell prompt to render. Then type the - // command character-by-character (chunked) so Ink-TUI - // doesn't mistake it for a paste block. Trailing Enter is - // a SEPARATE frame for the same reason. - (async () => { - await new Promise((r) => setTimeout(r, 1200)); - if (disposedRef.current) return; - if (!ws || ws.readyState !== WebSocket.OPEN) return; - const cmd = `claude --dangerously-skip-permissions --resume ${autoResume.sessionId}`; - await typeIntoPty((o) => send(o), cmd); - if (disposedRef.current) return; - if (!ws || ws.readyState !== WebSocket.OPEN) return; - send({ type: 'input', data: '\r' }); - })(); + function openWs() { + const ws = new WebSocket(ptyWsUrl()); + wsRef.current = ws; + + ws.addEventListener('open', () => { + const cols = term.cols; + const rows = term.rows; + // Reattach if we have a ptyId; fresh spawn otherwise (strip ptyId field) + if (spawn && spawn.ptyId) { + const msg = { type: 'spawn', ptyId: spawn.ptyId, cols, rows }; + console.log('[pty] → spawn (reattach)', msg); + ws.send(JSON.stringify(msg)); + } else { + // Strip any ptyId from a prior session — this is a fresh spawn + const spawnMsg = { type: 'spawn', cols, rows }; + if (spawn) { + const { ptyId: _ignored, ...rest } = spawn; + Object.assign(spawnMsg, rest); } - - // Restart-ping: if this PTY is the resume for a session that was - // restored from persisted layout (i.e. the viewer just booted), - // and we haven't pinged that session yet this boot, write the - // "SOFTWARE RESTARTED" message after a longer delay so the - // shell has run `claude --resume` AND claude has finished its - // startup handshake. - // Accepts either the legacy session spawn shape (spawn.sessionId, - // pre-v1.1.0) or the shell-wrap shape (spawn._autoResume.sessionId). - const sid = spawn?._autoResume?.sessionId || spawn?.sessionId; - if ( - sid - && window._restartPingPending.has(sid) - && !window._restartPingFired.has(sid) - ) { - window._restartPingFired.add(sid); - window._restartPingPending.delete(sid); - setTimeout(() => { - if (disposedRef.current) return; - if (!ws || ws.readyState !== WebSocket.OPEN) return; - // CRITICAL: send text FIRST, wait, then Enter as a SEPARATE - // WS frame. If we send text+\r together, Ink-TUI treats it - // as bracketed paste — the trailing \r gets interpreted as - // "confirm current menu option" (v1.0.0 bug where this - // auto-picked "compact summary" on the resume-choice menu) - // AND the ping text lands in the chat input unsent. - send({ type: 'input', data: RESTART_PING_TEXT }); - setTimeout(() => { + console.log('[pty] → spawn', spawnMsg); + ws.send(JSON.stringify(spawnMsg)); + } + }); + + ws.addEventListener('message', (ev) => { + if (disposedRef.current) return; + let msg; + try { msg = JSON.parse(ev.data); } + catch { console.warn('[pty] non-JSON frame', ev.data); return; } + console.log('[pty] ← ', msg.type, msg); + switch (msg.type) { + case 'ready': { + setStatus('ready'); + onReady && onReady(msg.id); + onPtyReady && onPtyReady(msg.id); + reconnectCountRef.current = 0; + + // v1.1.0 (#47): shell-wrap session tabs. If this pane was + // opened via "In viewer" on a session, it spawned a shell + // (cmd.exe) in the session's cwd — NOT `claude --resume` + // directly. Here we type the resume command into the shell + // so claude takes over the PTY. When the user later runs + // `/exit`, claude quits and the shell prompt returns — the + // tab stays alive and reusable. + const autoResume = spawn?._autoResume; + if ( + autoResume?.sessionId + && msg.id + && !window._autoResumeTyped.has(msg.id) + ) { + window._autoResumeTyped.add(msg.id); + // Wait ~1.2s for the shell prompt to render. Then type the + // command character-by-character (chunked) so Ink-TUI + // doesn't mistake it for a paste block. Trailing Enter is + // a SEPARATE frame for the same reason. + (async () => { + await new Promise((r) => setTimeout(r, 1200)); if (disposedRef.current) return; - if (!ws || ws.readyState !== WebSocket.OPEN) return; + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + const cmd = `claude --dangerously-skip-permissions --resume ${autoResume.sessionId}`; + await typeIntoPty((o) => send(o), cmd); + if (disposedRef.current) return; + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; send({ type: 'input', data: '\r' }); - }, 500); - }, RESTART_PING_DELAY_MS); + })(); + } + + // Restart-ping: if this PTY is the resume for a session that was + // restored from persisted layout (i.e. the viewer just booted), + // and we haven't pinged that session yet this boot, write the + // "SOFTWARE RESTARTED" message after a longer delay so the + // shell has run `claude --resume` AND claude has finished its + // startup handshake. + // Accepts either the legacy session spawn shape (spawn.sessionId, + // pre-v1.1.0) or the shell-wrap shape (spawn._autoResume.sessionId). + const sid = spawn?._autoResume?.sessionId || spawn?.sessionId; + if ( + sid + && window._restartPingPending.has(sid) + && !window._restartPingFired.has(sid) + ) { + window._restartPingFired.add(sid); + window._restartPingPending.delete(sid); + setTimeout(() => { + if (disposedRef.current) return; + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + // CRITICAL: send text FIRST, wait, then Enter as a SEPARATE + // WS frame. If we send text+\r together, Ink-TUI treats it + // as bracketed paste — the trailing \r gets interpreted as + // "confirm current menu option" (v1.0.0 bug where this + // auto-picked "compact summary" on the resume-choice menu) + // AND the ping text lands in the chat input unsent. + send({ type: 'input', data: RESTART_PING_TEXT }); + setTimeout(() => { + if (disposedRef.current) return; + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + send({ type: 'input', data: '\r' }); + }, 500); + }, RESTART_PING_DELAY_MS); + } + break; } - break; - } - case 'output': { - const data = msg.data || ''; - term.write(data); - // Auto-select "Resume full session as-is" if Claude Code asks. - // Send digit `2` — Ink's select component takes that as a - // one-keystroke pick for the 2nd option. Deduped per sessionId - // per viewer boot. Supports both the legacy spawn shape - // (spawn.sessionId) and v1.1.0 shell-wrap (spawn._autoResume. - // sessionId) so the auto-pick fires regardless of which path - // seeded the pane. - const sid = spawn?._autoResume?.sessionId || spawn?.sessionId; - if ( - sid - && typeof data === 'string' - && data.includes(RESUME_PROMPT_MARKER) - && !window._resumePromptHandled.has(sid) - ) { - window._resumePromptHandled.add(sid); - // Small delay so the prompt is fully rendered before we - // answer — firing input before Ink finishes laying out the - // options can be dropped. - setTimeout(() => { - if (disposedRef.current) return; - if (!ws || ws.readyState !== WebSocket.OPEN) return; - send({ type: 'input', data: RESUME_PROMPT_PICK_FULL }); - }, 400); + case 'output': { + const data = msg.data || ''; + term.write(data); + // Auto-select "Resume full session as-is" if Claude Code asks. + // Send digit `2` — Ink's select component takes that as a + // one-keystroke pick for the 2nd option. Deduped per sessionId + // per viewer boot. Supports both the legacy spawn shape + // (spawn.sessionId) and v1.1.0 shell-wrap (spawn._autoResume. + // sessionId) so the auto-pick fires regardless of which path + // seeded the pane. + const sid = spawn?._autoResume?.sessionId || spawn?.sessionId; + if ( + sid + && typeof data === 'string' + && data.includes(RESUME_PROMPT_MARKER) + && !window._resumePromptHandled.has(sid) + ) { + window._resumePromptHandled.add(sid); + // Small delay so the prompt is fully rendered before we + // answer — firing input before Ink finishes laying out the + // options can be dropped. + setTimeout(() => { + if (disposedRef.current) return; + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; + send({ type: 'input', data: RESUME_PROMPT_PICK_FULL }); + }, 400); + } + break; } - break; + case 'exit': + setStatus('exited'); + onExit && onExit(msg.code); + // Leave the terminal visible but disable input — user can see + // the final output of a crashed shell. + break; + case 'error': + // If the ptyId we tried to reattach to no longer exists, + // signal the parent so it can clear the ptyId and retry a + // fresh spawn. Don't set error status here — the effect will + // rerun once spawn is updated. + if (spawn && spawn.ptyId && String(msg.message || '').includes('not found')) { + ptyIdNotFoundRef.current = true; + onPtyReady && onPtyReady(null); + return; + } + setStatus('error'); + setError(String(msg.message || 'server error')); + break; } - case 'exit': + }); + + ws.addEventListener('error', (ev) => { + if (disposedRef.current) return; + console.error('[pty] ws error', ev); + // Don't set error here — the close handler will fire next and + // decide whether to reconnect or mark as exited. + }); + + ws.addEventListener('close', (ev) => { + if (ptyIdNotFoundRef.current) return; // effect will rerun after spawn update + if (disposedRef.current) return; + console.log('[pty] ws closed', ev.code, ev.reason); + if (statusRef.current === 'exited') return; + if (window._daemonToken && reconnectCountRef.current < 8) { + reconnectCountRef.current += 1; + setStatus('connecting'); + setTimeout(openWs, 2000); + } else { setStatus('exited'); - onExit && onExit(msg.code); - // Leave the terminal visible but disable input — user can see - // the final output of a crashed shell. - break; - case 'error': - setStatus('error'); - setError(String(msg.message || 'server error')); - break; - } - }); - - ws.addEventListener('error', (ev) => { - if (disposedRef.current) return; - console.error('[pty] ws error', ev); - setStatus('error'); - setError('websocket error'); - }); - - ws.addEventListener('close', (ev) => { - if (disposedRef.current) return; - console.log('[pty] ws closed', ev.code, ev.reason); - if (status !== 'exited' && status !== 'error') { - setStatus('exited'); - } - }); + } + }); + } // Forward keystrokes → server const inputDisp = term.onData((data) => { @@ -422,12 +465,15 @@ function TerminalPane({ spawn, onExit, onReady, className, paneId }) { }); ro.observe(hostRef.current); + openWs(); + return () => { disposedRef.current = true; try { ro.disconnect(); } catch {} try { inputDisp.dispose(); resizeDisp.dispose(); } catch {} try { - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + const ws = wsRef.current; + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { ws.close(1000, 'pane-unmount'); } } catch {} diff --git a/backend/updater.py b/backend/updater.py index 38d43c1..13c48f4 100644 --- a/backend/updater.py +++ b/backend/updater.py @@ -34,6 +34,10 @@ log = logging.getLogger(__name__) +# Set to True by daemon/__main__.py at startup so updater knows it's +# running inside the long-lived daemon process (Phase 9 two-binary split). +DAEMON_MODE = False + RELEASES_API = "https://api.github.com/repos/MenachemBarak/AgentCLISessionManager/releases/latest" # From v1.0.0 onwards the product is branded "AgentManager". Release # workflow publishes BOTH asset names for the transition window so @@ -203,7 +207,12 @@ def download_and_stage() -> dict[str, str | bool]: return {"ok": False, "message": "self-update only available in the packaged .exe"} exe_path = Path(sys.executable).resolve() - stage_path = exe_path.with_suffix(exe_path.suffix + ".new") + if DAEMON_MODE: + from daemon.bootstrap import state_dir + + stage_path = state_dir() / "staged-ui.exe" + else: + stage_path = exe_path.with_suffix(exe_path.suffix + ".new") # Download with progress try: @@ -377,6 +386,49 @@ def apply_update() -> dict[str, str | bool | int]: } +def apply_ui_only_update() -> dict[str, str | bool | int]: + """Swap only the UI exe. Daemon keeps running. PTYs survive.""" + import platform as _platform + + if _platform.system() != "Windows": + return {"ok": False, "message": "apply is Windows-only for now"} + if not getattr(sys, "frozen", False): + return {"ok": False, "message": "self-apply only available in the packaged .exe"} + if not DAEMON_MODE: + return {"ok": False, "message": "use /api/update/apply in non-daemon mode"} + + with STATE.lock: + staged = STATE.staged_path + if not staged or not Path(staged).exists(): + return {"ok": False, "message": "no staged update"} + + # UI exe lives next to daemon exe + daemon_exe = Path(sys.executable).resolve() + ui_exe = daemon_exe.parent / "AgentManager.exe" + if not ui_exe.exists(): + return {"ok": False, "message": f"UI exe not found: {ui_exe}"} + + with _APPLY_LOCK: + staged_path = Path(staged).resolve() + pid = os.getpid() # for logging only + log_path = daemon_exe.parent / "update-swap-ui.log" + script_dir = Path(tempfile.gettempdir()) / "agentmanager" + script_dir.mkdir(parents=True, exist_ok=True) + script_path = script_dir / f"update-swap-ui-{pid}.cmd" + script_path.write_text( + _windows_swap_script(ui_exe, staged_path, 0, log_path), + encoding="ascii", + ) + creationflags = 0x08000000 | 0x00000008 | 0x00000200 + subprocess.Popen( + ["cmd.exe", "/c", str(script_path)], + creationflags=creationflags, + close_fds=True, + cwd=str(script_dir), + ) + return {"ok": True, "message": "UI swap helper launched; close the UI window to apply"} + + def remove_stale_old_file() -> None: """When the current process IS the .new that was swapped in by the user, a stale `.old` exe may linger next to us. Best-effort cleanup. diff --git a/daemon/__main__.py b/daemon/__main__.py index 9a97d17..367db2d 100644 --- a/daemon/__main__.py +++ b/daemon/__main__.py @@ -44,6 +44,10 @@ def main() -> int: app.state.require_bearer_token = token app.state.daemon_version = __version__ + from backend import updater as _updater + + _updater.DAEMON_MODE = True + with lock_cm: uvicorn.run(app, host=host, port=port, log_level=log_level) return 0 diff --git a/pyinstaller-daemon.spec b/pyinstaller-daemon.spec new file mode 100644 index 0000000..dc12b6e --- /dev/null +++ b/pyinstaller-daemon.spec @@ -0,0 +1,75 @@ +# PyInstaller spec for AgentManager-Daemon.exe (ADR-18 Phase 9). +# +# Builds the long-lived daemon binary that owns all PTY sessions and runs +# uvicorn on 127.0.0.1:8765. No webview — this is a headless service. +# console=True because the process is spawned DETACHED so no console window +# is actually visible; the flag just means no Windows subsystem override. +# +# Build: pyinstaller pyinstaller-daemon.spec +# Output: dist/AgentManager-Daemon.exe + +from pathlib import Path + +from PyInstaller.utils.hooks import collect_submodules + +ROOT = Path(SPECPATH).resolve() # noqa: F821 + +datas = [ + (str(ROOT / "backend" / "frontend"), "backend/frontend"), + (str(ROOT / "hooks"), "hooks"), +] + +hiddenimports = ( + collect_submodules("uvicorn") + + collect_submodules("uvicorn.logging") + + collect_submodules("uvicorn.loops") + + collect_submodules("uvicorn.protocols") + + collect_submodules("uvicorn.lifespan") + + collect_submodules("sse_starlette") + + collect_submodules("watchdog.observers") + + [ + "backend.app", + "backend.cli", + "backend.__version__", + "daemon.bootstrap", + "daemon.launcher", + "daemon.uninstall", + "daemon.__main__", + ] +) + +a = Analysis( # noqa: F821 + ["daemon/__main__.py"], + pathex=[str(ROOT)], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=["webview", "tkinter", "matplotlib", "numpy", "pandas", "scipy"], + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data) # noqa: F821 + +exe = EXE( # noqa: F821 + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="AgentManager-Daemon", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) From 224d3f0916fcda9c0bd59554c04d537c06a6b0ef Mon Sep 17 00:00:00 2001 From: barakmen Date: Wed, 29 Apr 2026 17:10:37 +0300 Subject: [PATCH 2/5] fix(ci): mypy type-ignore for webview.destroy + update phase-9 test contract webview.destroy() exists at runtime but is absent from pywebview's mypy stubs; add type: ignore[attr-defined] to silence the false positive. The phase-7 stub test expected 501 until Phase 9 shipped; now that apply-ui-only is implemented it returns 200/ok=false in non-daemon test env, so update the assertion accordingly. Co-Authored-By: Claude Sonnet 4.6 --- backend/cli.py | 2 +- tests/test_daemon_phase7_stubs.py | 21 ++++++++++----------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/backend/cli.py b/backend/cli.py index c3e8447..4acbe26 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -263,7 +263,7 @@ def close_for_update(self) -> None: try: import webview as _wv - _wv.destroy() + _wv.destroy() # type: ignore[attr-defined] except Exception: pass diff --git a/tests/test_daemon_phase7_stubs.py b/tests/test_daemon_phase7_stubs.py index 9e244ca..f72f7a1 100644 --- a/tests/test_daemon_phase7_stubs.py +++ b/tests/test_daemon_phase7_stubs.py @@ -1,9 +1,8 @@ -"""Phase 7 stub tests — contract guard for the dual-asset update API. +"""Phase 7/9 contract tests for the dual-asset update API. -Asserts the two new endpoints exist and return a machine-parseable 501 -with `code=DAEMON_NOT_SPLIT` until Phase 9 ships the two-binary build. -Locks the API shape so frontend code + e2e fixtures can target a -stable contract today. +Phase 7: endpoints existed as 501 stubs. +Phase 9: apply-ui-only is real; in non-frozen/non-daemon test env it +returns 200 {"ok": false} with a reason message instead of 501. """ from __future__ import annotations @@ -20,14 +19,14 @@ def client(): return TestClient(app) -def test_apply_ui_only_returns_501_with_reason_code(client): +def test_apply_ui_only_endpoint_exists_and_responds(client): + # Phase 9: endpoint is implemented. In the test env (not frozen, not + # daemon mode) it returns 200 with ok=False and a message. r = client.post("/api/update/apply-ui-only") - assert r.status_code == 501 + assert r.status_code == 200 body = r.json() - # FastAPI wraps HTTPException(detail=dict) as {"detail": {...}}. - detail = body["detail"] - assert detail["code"] == "DAEMON_NOT_SPLIT" - assert "Phase 9" in detail["message"] + assert body["ok"] is False + assert "message" in body def test_apply_daemon_returns_501_with_reason_code(client): From c662576650a93f6ad2909d445dc04edc65e98754 Mon Sep 17 00:00:00 2001 From: barakmen Date: Wed, 29 Apr 2026 18:01:44 +0300 Subject: [PATCH 3/5] =?UTF-8?q?fix(terminal):=20break=20onPtyReady?= =?UTF-8?q?=E2=86=92spawn=E2=86=92effect=20feedback=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When onPtyReady fired, the parent wrote ptyId back into spawn, changing JSON.stringify(spawn) and re-triggering the WS effect. The cleanup closed the WS which destroyed the PTY under Phase-5 rules (fresh-spawn PTYs are torn down on WS close), making the subsequent reattach fail → loop. Fix: use spawnNonce (spawn without ptyId) as the effect key. ptyId changes never re-run the effect. For the "ptyId not found → need fresh spawn" case, increment freshSpawnCount state instead of relying on the parent removing ptyId from spawn. All spawn.xxx refs inside the effect now read from spawnRef.current so they see the latest value without stale closure issues. Fixes: split-preserves-pty remount, shell-wrap-runtime no-input, and legacy-layout-migration re-persist failures. Co-Authored-By: Claude Sonnet 4.6 --- backend/frontend/terminal-pane.jsx | 43 +++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/backend/frontend/terminal-pane.jsx b/backend/frontend/terminal-pane.jsx index 742b499..519c018 100644 --- a/backend/frontend/terminal-pane.jsx +++ b/backend/frontend/terminal-pane.jsx @@ -123,6 +123,23 @@ function TerminalPane({ spawn, onExit, onReady, onPtyReady, className, paneId }) const ptyIdNotFoundRef = React.useRef(false); const [status, setStatusState] = React.useState('connecting'); // connecting|ready|exited|error const [error, setError] = React.useState(null); + // Incremented when ptyId reattach fails and we need a fresh spawn. + // This is the ONLY way the effect re-runs for fresh-spawn-after-fail, + // because ptyId is excluded from the effect key (see spawnNonce below). + const [freshSpawnCount, setFreshSpawnCount] = React.useState(0); + + // Always-current ref so the effect can read the latest spawn without + // ptyId changes re-triggering the effect. + const spawnRef = React.useRef(spawn); + spawnRef.current = spawn; + + // Effect key: spawn identity WITHOUT ptyId. Adding ptyId (from onPtyReady) + // must NOT restart the WS — doing so closes the WS which destroys the PTY + // under Phase-5 rules (fresh-spawn PTYs are torn down on WS close). + const spawnNonce = React.useMemo(() => { + const { ptyId: _, ...rest } = spawn || {}; + return JSON.stringify(rest); + }, [spawn]); // Keep statusRef in sync with state on every update const setStatus = React.useCallback((s) => { @@ -274,16 +291,17 @@ function TerminalPane({ spawn, onExit, onReady, onPtyReady, className, paneId }) ws.addEventListener('open', () => { const cols = term.cols; const rows = term.rows; + const currentSpawn = spawnRef.current; // Reattach if we have a ptyId; fresh spawn otherwise (strip ptyId field) - if (spawn && spawn.ptyId) { - const msg = { type: 'spawn', ptyId: spawn.ptyId, cols, rows }; + if (currentSpawn && currentSpawn.ptyId) { + const msg = { type: 'spawn', ptyId: currentSpawn.ptyId, cols, rows }; console.log('[pty] → spawn (reattach)', msg); ws.send(JSON.stringify(msg)); } else { // Strip any ptyId from a prior session — this is a fresh spawn const spawnMsg = { type: 'spawn', cols, rows }; - if (spawn) { - const { ptyId: _ignored, ...rest } = spawn; + if (currentSpawn) { + const { ptyId: _ignored, ...rest } = currentSpawn; Object.assign(spawnMsg, rest); } console.log('[pty] → spawn', spawnMsg); @@ -311,7 +329,7 @@ function TerminalPane({ spawn, onExit, onReady, onPtyReady, className, paneId }) // so claude takes over the PTY. When the user later runs // `/exit`, claude quits and the shell prompt returns — the // tab stays alive and reusable. - const autoResume = spawn?._autoResume; + const autoResume = spawnRef.current?._autoResume; if ( autoResume?.sessionId && msg.id @@ -342,7 +360,7 @@ function TerminalPane({ spawn, onExit, onReady, onPtyReady, className, paneId }) // startup handshake. // Accepts either the legacy session spawn shape (spawn.sessionId, // pre-v1.1.0) or the shell-wrap shape (spawn._autoResume.sessionId). - const sid = spawn?._autoResume?.sessionId || spawn?.sessionId; + const sid = spawnRef.current?._autoResume?.sessionId || spawnRef.current?.sessionId; if ( sid && window._restartPingPending.has(sid) @@ -379,7 +397,7 @@ function TerminalPane({ spawn, onExit, onReady, onPtyReady, className, paneId }) // (spawn.sessionId) and v1.1.0 shell-wrap (spawn._autoResume. // sessionId) so the auto-pick fires regardless of which path // seeded the pane. - const sid = spawn?._autoResume?.sessionId || spawn?.sessionId; + const sid = spawnRef.current?._autoResume?.sessionId || spawnRef.current?.sessionId; if ( sid && typeof data === 'string' @@ -409,9 +427,10 @@ function TerminalPane({ spawn, onExit, onReady, onPtyReady, className, paneId }) // signal the parent so it can clear the ptyId and retry a // fresh spawn. Don't set error status here — the effect will // rerun once spawn is updated. - if (spawn && spawn.ptyId && String(msg.message || '').includes('not found')) { + if (spawnRef.current && spawnRef.current.ptyId && String(msg.message || '').includes('not found')) { ptyIdNotFoundRef.current = true; - onPtyReady && onPtyReady(null); + onPtyReady && onPtyReady(null); // clears ptyId from persisted layout + setFreshSpawnCount((n) => n + 1); // triggers effect re-run for fresh spawn return; } setStatus('error'); @@ -428,7 +447,7 @@ function TerminalPane({ spawn, onExit, onReady, onPtyReady, className, paneId }) }); ws.addEventListener('close', (ev) => { - if (ptyIdNotFoundRef.current) return; // effect will rerun after spawn update + if (ptyIdNotFoundRef.current) return; // setFreshSpawnCount triggers re-run instead if (disposedRef.current) return; console.log('[pty] ws closed', ev.code, ev.reason); if (statusRef.current === 'exited') return; @@ -482,8 +501,8 @@ function TerminalPane({ spawn, onExit, onReady, onPtyReady, className, paneId }) termRef.current = null; fitRef.current = null; }; - // eslint-disable-next-line react-hooks/exhaustive-deps — spawn change should remount - }, [JSON.stringify(spawn)]); + // eslint-disable-next-line react-hooks/exhaustive-deps — spawnNonce excludes ptyId intentionally + }, [spawnNonce, freshSpawnCount]); // Status ribbon — tiny, non-intrusive. const ribbon = (() => { From 90fa1e18a762dccfdda43a16eb7e60559dab685b Mon Sep 17 00:00:00 2001 From: barakmen Date: Wed, 29 Apr 2026 18:31:48 +0300 Subject: [PATCH 4/5] fix(e2e): update resume-prompt-auto-pick contract regex for spawnRef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source-code contract test checked for spawn?._autoResume — after the spawnRef refactor it's now spawnRef.current?._autoResume. Co-Authored-By: Claude Sonnet 4.6 --- e2e/tests/feature/resume-prompt-auto-pick.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/tests/feature/resume-prompt-auto-pick.spec.ts b/e2e/tests/feature/resume-prompt-auto-pick.spec.ts index 6891a68..df30310 100644 --- a/e2e/tests/feature/resume-prompt-auto-pick.spec.ts +++ b/e2e/tests/feature/resume-prompt-auto-pick.spec.ts @@ -45,5 +45,6 @@ test('terminal-pane auto-picks "Resume full session as-is" (v0.9.10)', async ({ // sid extraction accepts both legacy (spawn.sessionId) and v1.1.0 // shell-wrap (spawn._autoResume.sessionId) shapes — otherwise the // auto-pick never fires for shell-wrap tabs. - expect(src).toMatch(/_autoResume\?\.sessionId\s*\|\|\s*spawn\?\.sessionId/); + // spawnRef.current is used (not spawn directly) to avoid stale closure issues. + expect(src).toMatch(/_autoResume\?\.sessionId\s*\|\|\s*spawnRef\.current\?\.sessionId/); }); From 14bd35031b25fe15c95f37fec533d8a15b6e670d Mon Sep 17 00:00:00 2001 From: barakmen Date: Wed, 29 Apr 2026 18:47:32 +0300 Subject: [PATCH 5/5] fix(e2e): always start fresh test server, never reuse existing reuseExistingServer: true locally caused tests to hijack the user's running app on port 8769, injecting fake sessions into it. Setting it to false ensures tests always get a clean isolated backend. Co-Authored-By: Claude Sonnet 4.6 --- e2e/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index b0211b1..f2b8438 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -36,7 +36,7 @@ export default defineConfig({ : { command: 'python -m backend.cli --server-only --port 8769 --no-browser', url: 'http://127.0.0.1:8769/api/status', - reuseExistingServer: !process.env.CI, + reuseExistingServer: false, timeout: 30_000, cwd: '..', env: {