Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
16 changes: 4 additions & 12 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
13 changes: 12 additions & 1 deletion backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@
# 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
Expand Down Expand Up @@ -258,6 +258,16 @@
return 0


class _PywebviewApi:
def close_for_update(self) -> None:
try:
import webview as _wv

_wv.destroy() # type: ignore[attr-defined]
except Exception:

Check notice

Code scanning / CodeQL

Empty except Note

'except' clause does nothing but pass and there is no explanatory comment.
pass


def _launch_daemon_mode(webview_mod: Any) -> int | None:
"""Opt-in daemon-split launch path (ADR-18 / Task #42).

Expand Down Expand Up @@ -308,6 +318,7 @@
height=900,
resizable=True,
confirm_close=False,
js_api=_PywebviewApi(),
)
webview_mod.start()
return 0
Expand Down
97 changes: 90 additions & 7 deletions backend/frontend/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div data-testid="daemon-disconnected-banner" style={{
flexShrink: 0, padding: '6px 14px',
display: 'flex', alignItems: 'center', gap: 10,
background: 'rgba(200,90,90,0.12)',
borderBottom: '1px solid rgba(200,90,90,0.3)',
fontSize: 12, color: 'rgba(255,180,180,0.9)',
}}>
<span>⚠ Daemon disconnected — terminal sessions may be unavailable</span>
<button
data-testid="daemon-reconnect-btn"
onClick={() => fetch('/api/health').then(() => setDisconnected(false)).catch(() => {})}
style={{
marginLeft: 'auto', background: 'transparent',
border: '1px solid rgba(200,90,90,0.4)',
borderRadius: 4, padding: '2px 10px',
color: 'rgba(255,180,180,0.9)', cursor: 'pointer', fontSize: 11,
}}>Reconnect</button>
</div>
);
}

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+
Expand Down Expand Up @@ -120,6 +159,7 @@ function WindowChrome({ children, tweaks, onToggleTweaks, selectedCount, activeC
</div>

<UpdateBanner accent={ACCENTS[tweaks.accent]}/>
<DaemonDisconnectedBanner/>

{/* Body: two-pane */}
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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).
Expand All @@ -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.
Expand Down Expand Up @@ -963,7 +1041,12 @@ function RightPane({ selected, accent, onOpen, onActiveSessionChange }) {
onFocus={setFocusedPaneId}
onUpdateTree={(updater) => updateActiveTree(updater)}/>
{panes.map((p) => (
<TerminalPane key={p.id} paneId={p.id} spawn={p.spawn}/>
<TerminalPane
key={p.id}
paneId={p.id}
spawn={p.spawn}
onPtyReady={(ptyId) => handlePtyReady(t.id, p.id, ptyId)}
/>
))}
</div>
);
Expand Down
Loading
Loading