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):