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..4acbe26 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() # type: ignore[attr-defined] + 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..519c018 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,8 +118,34 @@ 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); + // 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) => { + 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 @@ -174,6 +200,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 +242,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 +284,182 @@ 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; + const currentSpawn = spawnRef.current; + // Reattach if we have a ptyId; fresh spawn otherwise (strip ptyId field) + 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 (currentSpawn) { + const { ptyId: _ignored, ...rest } = currentSpawn; + 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 = spawnRef.current?._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 (!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 (!ws || ws.readyState !== WebSocket.OPEN) 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 = spawnRef.current?._autoResume?.sessionId || spawnRef.current?.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 = spawnRef.current?._autoResume?.sessionId || spawnRef.current?.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 (spawnRef.current && spawnRef.current.ptyId && String(msg.message || '').includes('not found')) { + ptyIdNotFoundRef.current = true; + onPtyReady && onPtyReady(null); // clears ptyId from persisted layout + setFreshSpawnCount((n) => n + 1); // triggers effect re-run for fresh spawn + 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; // setFreshSpawnCount triggers re-run instead + 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 +484,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 {} @@ -436,8 +501,8 @@ function TerminalPane({ spawn, onExit, onReady, 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 = (() => { 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/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: { 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/); }); 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, +) 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):