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
3 changes: 2 additions & 1 deletion .ai/contexts/ipc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ This file is the **canonical inventory** of the IPC surface. When you add a new
| `get-active-sessions` | — | `{sessionId, busy}[]` | Currently open PTY sessions plus each one's live `_cliBusy` flag — see "Busy-state reconciliation" below. |
| `get-active-terminals` | — | `Terminal[]` | Active PTY identifiers |
| `open-terminal` | `(id, projectPath, isNew, sessionOptions)` | `{ok, error?, mcpActive}` | Spawn or attach a PTY. |
| `stop-session` | `(id)` | `{ok}` | Kill the PTY for `id`. |
| `stop-session` | `(id)` | `{ok}` | Kill the PTY for `id`. Local only — a remote-attach session's PTY is the local ssh attach client, so this only detaches it; see `remote-stop-session` for the real remote "stop". |
| `remote-stop-session` | `({alias, sessionId})` | `{ok, method?, error?}` | The "stop" verb for a remote-ssh session (`.ai/contexts/session-state.md`, "Lifecycle decisions") — kills the process on the host at the narrowest matching tmux scope when the descriptor names a target (`kill-pane` when it names a pane, `kill-window` when it names only a window — never `kill-session`, siblings share the tmux session), confirms death with a `/proc` poll, and falls back to `kill -TERM` then `kill -KILL` if the tmux kill didn't stick or no tmux target was named; refuses with the pid-reuse message when the pid now belongs to a non-claude process. `method` is `'tmux-pane' \| 'tmux-window' \| 'pid-term' \| 'pid-kill'`. On success also drops the host's in-memory descriptor, forces a host refresh, and detaches the local attach PTY if one was open. Handler in `main.js`, adapter in `remote-stop.js` (reuses `remote-attach.js`'s pid-reuse probe, not a fork). |
| `toggle-star` | `(id)` | `{ok}` | Star/unstar in session_meta. |
| `rename-session` | `(id, name)` | `{ok}` | Set customTitle. |
| `archive-session` | `(id, archived)` | `{ok}` | Move to archive. |
Expand Down
93 changes: 93 additions & 0 deletions .ai/contexts/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,85 @@ what actually shipped, not the whole plan.
`localTranscriptStates`) so `has-busy-agents` survives a full
`renderProjects()` re-render for those two kinds, the same way it already
did for local-pty via `activeSubagentsByParent`.
- **Lifecycle decisions (2026-09-11): done.** The two verbs — detach and
stop — are both real now; see "The two lifecycle verbs: detach and stop"
below.

## The two lifecycle verbs: detach and stop

Two facts the domain carries separately: **process liveness** (the CLI is
running — local pid, or a remote descriptor with an ALIVE marker, #262) and
**attached view** (Switchboard holds a PTY / an ssh attach for it). A row is
active because the process is alive, not because a tab is open.

| verb | local-pty | remote-ssh |
|---|---|---|
| detach | not offered — closing a session's view is a stop (unchanged) | `stop-session`'s pre-existing behavior: `killPty` → the tmux adapter's `detach()` (`remote-attach.js`) — ends the local ssh client, optionally restores tmux options (solo attach, #256). Still reachable today wherever `activeSessions` cleanup calls `killPty` on a `kind: 'remote-attach'` session without a preceding `remote-stop-session` call, and via `close-terminal`'s ordinary detach (marks `rendererAttached=false`, kills nothing). |
| stop | kill the PTY (`stop-session`, unchanged) | **new**: `remote-stop-session` IPC (`{alias, sessionId}`) — kills the process on the host itself, same control and same confirmation dialog as local. No session locked by name or role. |

**No new dialog component.** `public/stop-session-ui.js`'s `resolveSessionStop(session)`
is the only thing that differs between a local and a remote stop: which IPC
to call, and the `confirm()` text (the host alias is named for a remote
session). `app.js`'s `confirmAndStopSession` is still the single call site of
the dialog and the single stop control (the sidebar row's `.session-stop-btn`,
the terminal header's stop button, and the grid card's stop button all funnel
through it) — it now asks `resolveSessionStop` which IPC to call instead of
always calling `stopSession`.

**The remote stop, main-side (`remote-stop.js`).** `createRemoteStopAdapter().stop(alias, descriptor)`
builds one non-interactive ssh command (same `buildRemoteCommandArgs` transport
as `remote-attach.js`'s probe/restore calls) that: (1) reuses
`remote-attach.js`'s `buildProcCmdlineCheck`/pid-reuse guard verbatim — a
recycled pid is refused with the exact wording the attach path uses, not a
forked copy; (2) when the descriptor's `tmux` field parses, discovers the
socket from `/proc/<pid>/environ` (identical to the attach probe) and kills at
the **narrowest matching scope, never the session**: `tmux kill-pane -t
<target>` when the target names a pane, `tmux kill-window -t <target>` when it
names only a window. `kill-session` is never emitted — the VPS harness runs
several CLIs as windows/panes of one shared tmux session, and a session-wide
kill would take every sibling down with the one being stopped; a tmux exit
code of 0 only means tmux accepted the request, so this is confirmed with the
same `/proc/<pid>` poll as step (3) below before the tmux success marker is
reported — a survivor falls through to (3) instead; (3) otherwise, or if the
tmux kill fails (or its target survives the poll), falls back to `kill -TERM
<pid>`, polls `/proc/<pid>` for up to ~3s (six 0.5s ticks), then `kill -KILL`
once if it is still there. Returns `{ok, method}` where `method` is
`'tmux-pane' | 'tmux-window' | 'pid-term' | 'pid-kill'`, or `{ok:false, error}`.
`targetHasPane()` reads the pane/window distinction off the target string
itself (a "." after the session prefix means a pane component follows,
matching the grammar `TMUX_FIELD_RE` already validates) — no new parsing of
the descriptor is added. The pane-vs-window suffix convention itself comes
from the CLI's own descriptor writer on the VPS side, not measured against
that writer's source from here; the `/proc/<pid>` death poll after the tmux
kill (above) is what bounds the blast radius if that assumption is ever
wrong — a wrongly-classified target still ends up TERM'd/KILL'd by pid once
the poll finds it still alive, instead of the stop silently reporting
success on a process the tmux call never actually touched.

**On a successful stop, `main.js`'s `remote-stop-session` handler**: drops the
descriptor from `remote-index.js`'s in-memory list (`dropRemoteSession(alias,
sessionId)`) and calls `notifyRendererProjectsChanged()` directly — a forced
`refreshHostNow` alone does not reliably `notify()` (only a folder-level jsonl
change does), so the row would otherwise wait for the next real host cycle
to reflect the kill; `refreshHostNow(alias, {force:true})` still runs
afterward, fire-and-forget, as the authoritative reconciliation once the host's
own next descriptor list confirms the process is gone. If this app held a
local ssh attach for the now-dead session (`session.kind === 'remote-attach'`),
`killPty` closes it too — the remote process is already gone, so there is
nothing left to detach *from*, but the local ssh client would otherwise linger
until it notices the far end closed on its own.

**The renderer side, immediately.** On a successful remote stop,
`public/remote-activity-ui.js`'s `applyRemoteStopped(sessionId)` applies
`liveness:'dead'`, `attached:false`, and — beyond what the issue text names,
needed so the adapter's own snapshot does not keep claiming a dead process is
still doing something — clears `busy`/`attention`/`agentsBusy` too, cancels
both of the adapter's own decay timers (activity and subagent-attribution),
and calls `purgeActivityFor(sessionId, 'remote-stop')` to drop the
parallel-fed `sessionBusyState`/`responseReadySessions`/`attentionSessions`
Map entries (see "migration status" above — two readers still consume those
Maps directly). This repaints the row's icon slot before the next
`get-projects` round-trip lands.

### The remote-ssh adapter (step 3)

Expand Down Expand Up @@ -380,3 +459,17 @@ from a completion signal it cannot verify — that is why the remote-ssh and
local-transcript `busy: false` transitions always pass `armReady: false` (see
"The remote-ssh adapter" and "The local-transcript adapter" above), not a
tri-state `busy: unknown`.

## Known limits

- **The remote-stop pid-reuse guard is weak.** `remote-stop.js`'s
`buildRefusalGuard` (and `remote-attach.js`'s probe it reuses verbatim)
decides "is this still the claude CLI" with `grep -qi claude` against
`/proc/<pid>/cmdline` — a process a user happens to launch with "claude"
anywhere in its argv (not the CLI itself) passes the same guard and can be
killed. Deferred, not implemented: hardening candidates are the `comm`
field from `/proc/<pid>/stat` (the kernel-recorded executable basename,
harder to spoof by argv alone) and the process start time (`/proc/<pid>/stat`
field 22, jiffies since boot) compared against the descriptor's own
recorded start time — a pid recycled fast enough to still say "claude" in
argv is caught by a start-time mismatch even when the cmdline check is not.
6 changes: 6 additions & 0 deletions docs/session-browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ The confirmation dialog states what will be removed: the project, how many files

If the session is still running it is stopped first when Switchboard knows it is live; otherwise the deletion is refused with a reason rather than pulling a transcript out from under a running process. Anything that resolves outside `~/.claude/projects` — a symlinked transcript, for instance — is refused and logged. A session that never started has no transcript to remove, so deleting it just clears the leftover card.

## Stop a running session

The stop button on a running session's card ends its process — for a local session and for a session on a declared remote host alike, with the same confirmation dialog. For a remote host session the dialog names the host, and stopping kills the process on that host, not just Switchboard's view of it.

A remote session you are not currently viewing keeps running on the host even though Switchboard is not attached to it; opening it again reattaches to the same process instead of starting a new one. Only the stop button ends it.

## Star and archive

- **Star** — right-click a session and choose Star, or use the star icon in the session header. Starred sessions appear at the top of their project group.
Expand Down
7 changes: 6 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,14 @@ const rendererCrossFileGlobals = {
setResponseReady: 'readonly',
setCliBusy: 'readonly',
setHasBusyAgents: 'readonly',
setIsAlive: 'readonly',
isSessionAlive: 'readonly',
paintSessionIcon: 'readonly',
// public/remote-activity-ui.js (remote-ssh adapter, see .ai/contexts/session-state.md)
setRemoteAttached: 'readonly',
applyRemoteStopped: 'readonly',
// public/stop-session-ui.js (pure stop-vs-detach decision, see .ai/contexts/session-state.md)
resolveSessionStop: 'readonly',
// read by sidebar.js's parentHasActiveSubagent() — see .ai/contexts/subagent-observability.md
remoteSessionStates: 'readonly',
// public/local-transcript-adapter.js (local-transcript adapter, see .ai/contexts/session-state.md)
Expand Down Expand Up @@ -317,7 +322,7 @@ module.exports = [
// Dual-mode helper: classic <script> in the renderer AND require()-d in tests.
// Same browser globals as the rest of public/, plus `module` for the CJS footer.
{
files: ['public/shortcuts.js', 'public/terminal-context-menu.js', 'public/terminal-manager.js', 'public/restore-plan.js'],
files: ['public/shortcuts.js', 'public/terminal-context-menu.js', 'public/terminal-manager.js', 'public/restore-plan.js', 'public/stop-session-ui.js'],
languageOptions: {
ecmaVersion: 2024,
sourceType: 'script',
Expand Down
29 changes: 29 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
}

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles');

Check warning on line 73 in main.js

View workflow job for this annotation

GitHub Actions / lint

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 73 in main.js

View workflow job for this annotation

GitHub Actions / lint

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { isSensitivePath, isAllowedMemoryPath: _isAllowedMemoryPath, resolveAllowedMemoryPath: _resolveAllowedMemoryPath, isKnownProjectRoot: _isKnownProjectRoot } = require('./ipc-path-validator');
Expand All @@ -81,6 +81,7 @@
const { handleTerminalInput } = require('./terminal-input');
const { createTriggerContext } = require('./trigger-context');
const { createTmuxAttachAdapter } = require('./remote-attach');
const { createRemoteStopAdapter } = require('./remote-stop');

setPtyOpLogger(log);

Expand Down Expand Up @@ -453,8 +454,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 457 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 457 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker,

Check warning on line 458 in main.js

View workflow job for this annotation

GitHub Actions / lint

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
scanFoldersViaWorker, setRemoteRoots, resolveFolderDir } = sessionCache;
const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file');

Expand Down Expand Up @@ -529,6 +530,9 @@
log,
});

// see .ai/contexts/session-state.md ("The two lifecycle verbs: detach and stop")
const remoteStopAdapter = createRemoteStopAdapter({ log });

// Joins the sidebar's remote sessions to the indexer's live descriptors so the
// renderer can route a click without ever naming an attach mechanism itself
// — see .ai/contexts/session-cache.md ("Remote hosts — tmux attach").
Expand Down Expand Up @@ -1661,6 +1665,31 @@
return { ok: true };
});

// --- IPC: remote-stop-session ---
// see .ai/contexts/session-state.md ("The two lifecycle verbs: detach and stop")
ipcMain.handle('remote-stop-session', async (_event, payload) => {
const alias = payload && payload.alias;
const sessionId = payload && payload.sessionId;
if (typeof alias !== 'string' || !alias || typeof sessionId !== 'string' || !sessionId) {
return { ok: false, error: 'invalid request' };
}
const descriptor = remoteIndexer.getRemoteSessions(alias).sessions.find(s => s.sessionId === sessionId);
if (!descriptor) return { ok: false, error: 'session not found on that host' };

const result = await remoteStopAdapter.stop(alias, descriptor);
if (result.ok) {
remoteIndexer.dropRemoteSession(alias, sessionId);
// see .ai/contexts/session-state.md ("The two lifecycle verbs: detach and stop")
notifyRendererProjectsChanged();
remoteIndexer.refreshHostNow(alias, { force: true }).catch(() => {});
const attachedSession = activeSessions.get(sessionId);
if (attachedSession && attachedSession.kind === 'remote-attach' && !attachedSession.exited) {
killPty(attachedSession, sessionId);
}
}
return result;
});

// --- IPC: toggle-star ---
ipcMain.handle('toggle-star', (_event, sessionId) => {
const starred = toggleStar(sessionId);
Expand Down Expand Up @@ -2170,7 +2199,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 2202 in main.js

View workflow job for this annotation

GitHub Actions / lint

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down
2 changes: 2 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ contextBridge.exposeInMainWorld('api', {
getActiveSessions: () => ipcRenderer.invoke('get-active-sessions'),
getActiveTerminals: () => ipcRenderer.invoke('get-active-terminals'),
stopSession: (id) => ipcRenderer.invoke('stop-session', id),
// see .ai/contexts/session-state.md ("The two lifecycle verbs: detach and stop")
remoteStopSession: (alias, sessionId) => ipcRenderer.invoke('remote-stop-session', { alias, sessionId }),
toggleStar: (id) => ipcRenderer.invoke('toggle-star', id),
renameSession: (id, name) => ipcRenderer.invoke('rename-session', id, name),
archiveSession: (id, archived) => ipcRenderer.invoke('archive-session', id, archived),
Expand Down
31 changes: 26 additions & 5 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -741,9 +741,29 @@ async function triggerRebuildAndSearch() {
}

// --- Stop session helper ---
async function confirmAndStopSession(sessionId) {
if (!confirm('Stop this session?')) return;
await window.api.stopSession(sessionId);
// see .ai/contexts/session-state.md ("The two lifecycle verbs: detach and stop")
// btn (optional): the clicked control, flashed on failure instead of alert() — see sidebar.js's session-delete-btn
async function confirmAndStopSession(sessionId, btn) {
const plan = resolveSessionStop(sessionMap.get(sessionId));
if (!confirm(plan.confirmText)) return;
const result = plan.remote
? await window.api.remoteStopSession(plan.alias, sessionId)
: await window.api.stopSession(sessionId);
if (result && result.ok === false) {
const message = result.error || 'unknown error';
console.error('[stop-session]', message);
// leave activePtyIds/the open view untouched on failure
if (btn) {
if (typeof window.flashButtonText === 'function') window.flashButtonText(btn, 'Failed', 1500);
const originalTitle = btn.title;
btn.title = message;
setTimeout(() => { btn.title = originalTitle; }, 3000);
}
return;
}
if (plan.remote && typeof applyRemoteStopped === 'function') {
applyRemoteStopped(sessionId);
}
activePtyIds.delete(sessionId);
if (!gridViewActive && activeSessionId === sessionId) {
setActiveSession(null);
Expand All @@ -755,7 +775,7 @@ async function confirmAndStopSession(sessionId) {

// --- Terminal header controls ---
terminalStopBtn.addEventListener('click', () => {
if (activeSessionId) confirmAndStopSession(activeSessionId);
if (activeSessionId) confirmAndStopSession(activeSessionId, terminalStopBtn);
});


Expand Down Expand Up @@ -851,7 +871,8 @@ function updateRunningIndicators() {
const footer = card.querySelector('.grid-card-footer');
if (footer) footer.children[0].textContent = running ? 'Running' : 'Stopped';
const stopBtn = card.querySelector('.grid-card-stop-btn');
if (stopBtn) stopBtn.style.display = running ? '' : 'none';
// is-alive: process alive on its host though unattached — see .ai/contexts/session-state.md
if (stopBtn) stopBtn.style.display = (running || isSessionAlive(sid)) ? '' : 'none';
}
}

Expand Down
5 changes: 3 additions & 2 deletions public/grid-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,11 @@ function wrapInGridCard(sessionId) {
stopBtn.className = 'grid-card-stop-btn';
stopBtn.title = 'Stop session';
stopBtn.innerHTML = '<svg width="10" height="10" viewBox="0 0 12 12" fill="currentColor"><rect x="2" y="2" width="8" height="8" rx="1"/></svg>';
stopBtn.style.display = activePtyIds.has(sessionId) ? '' : 'none';
// is-alive: process alive on its host though unattached — see .ai/contexts/session-state.md
stopBtn.style.display = (activePtyIds.has(sessionId) || isSessionAlive(sessionId)) ? '' : 'none';
stopBtn.onclick = (e) => {
e.stopPropagation();
confirmAndStopSession(sessionId);
confirmAndStopSession(sessionId, stopBtn);
};
header.appendChild(stopBtn);

Expand Down
2 changes: 2 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@
<script src="memory-workfiles-view.js"></script>
<!-- restore-plan.js decides working-set restore vs. defer-and-retry; consumed by app.js only. -->
<script src="restore-plan.js"></script>
<!-- stop-session-ui.js: pure stop-vs-detach decision for confirmAndStopSession; consumed by app.js only. -->
<script src="stop-session-ui.js"></script>
<script src="app.js"></script>
</body>
</html>
14 changes: 14 additions & 0 deletions public/remote-activity-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,20 @@ function setRemoteAttached(sessionId, attached) {
projectRemoteState(sessionId);
}

// see .ai/contexts/session-state.md ("The two lifecycle verbs: detach and stop")
function applyRemoteStopped(sessionId) {
clearRemoteActivityTimer(sessionId);
clearRemoteAgentsTimer(sessionId);
const state = remoteState(sessionId);
state.apply({ type: 'busy', active: false, armReady: false });
state.apply({ type: 'attention', active: false });
state.apply({ type: 'subagentCompleted', stillActive: false });
state.apply({ type: 'liveness', value: 'dead' });
state.apply({ type: 'attached', value: false });
projectRemoteState(sessionId);
purgeActivityFor(sessionId, 'remote-stop');
}

function seedRemoteActivity(session) {
if (!session || !session.remoteAlias) return;
applyRemoteDescriptor(session);
Expand Down
Loading
Loading