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
38 changes: 38 additions & 0 deletions .ai/contexts/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,44 @@ response-ready (unchanged)" pin both branches of the decision — the icon
slot's `session-icon--waiting`/`session-icon--response-ready` class and
title, side by side, so a future reader finds the split intentional.

#### A session main drops (issue #375)

Main dropping a local-pty session (`process-exited`, then `activeSessions.delete`)
is the one moment the renderer's running state for it has to end — in the
sidebar row, the status bar and the activity state. The `pty-gone` purge in
`updateRunningIndicators()` only runs when the running-set *signature* changes,
so it could not be the only owner: a `cli-busy-state` or notification that lands
after a poll already removed the id (a CLI stuck in an API retry loop keeps
rewriting its title while it is killed) re-armed `cli-busy` on a row the
signature gate then never revisited, and the status bar's `N running` was only
redrawn by `loadProjects()`.

- `onProcessExited` (`app.js`) removes the id from `activePtyIds`, calls
`dropLocalPtySession(id, 'process-exited')` for a non-remote row, and runs
`updateRunningIndicators()` at once instead of waiting for the next poll.
- `dropLocalPtySession` (`session-activity.js`) is the single drop: live
subagents first (`clearActiveSubagentsFor`), then `purgeActivityFor`. The
`pty-gone` scan uses the same helper.
- `updateRunningIndicators()` calls `renderDefaultStatus()` whenever the set
changed, so the status bar count follows `activePtyIds`.

**Pty generation.** `wireSessionPty` (`main.js`) gives every spawn the next value of
a monotonically increasing counter (`session.generation`). It is returned by all
three `open-terminal` replies and sent as the fifth argument of every
`process-exited` (the fork re-key's second send included). `app.js` records the
generation of the last reply per id (`ptyGenerations`) and `handleProcessExited`
ignores an exit older than it — no drop, no `closed`, no banner. An exit that
lands while `openSession` awaits the reply is buffered (`pendingOpens`);
`settlePtyOpen` then applies it unless the reply's generation is newer, so a
stale exit never touches the new pty and a fast-failing launch still reads
as exited. Chosen over suppressing the event in main because main cannot know
which exit the renderer has already seen when it is delivered.

Remote rows stay with the remote adapter (see "Row ownership"). Not covered: a
busy signal that arrives after `process-exited` — main sends none, the PTY is
gone. The pre-fix sequence is reasoned from the code, not reproduced live;
`test/dropped-session-state.test.js` pins each step.

### The local-transcript adapter (step 4)

`public/local-transcript-adapter.js` keeps one persistent
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
## Unreleased

### Fixed
- A session that has exited no longer keeps a busy dot in the sidebar, and the status bar's running count drops as soon as the session ends instead of waiting for the next refresh. (#375)
- Quitting, closing the window or reloading while a file in the file panel has unsaved edits now asks first, in any session, kept-aside tabs included: Save writes them (a file that changed on disk is not overwritten), Discard drops them, Cancel stays. If Switchboard does not answer within a few seconds, it closes anyway. (#373)
- The IDE Emulation label in a session's terminal header now says whether the CLI is connected: it reads "IDE Emulation" only while it is, "IDE Emulation: waiting for CLI" when Switchboard is listening but the CLI has not connected, and "IDE Emulation: failed" when it could not start for that session, with the reason in its tooltip. A session whose IDE Emulation port was already taken no longer shows the label as if it worked. (#320)
- On Windows, the file panel no longer opens or saves a credential file (such as one under `.ssh`) through its 8.3 short name or a `\\?\` path. (#390)
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ const rendererCrossFileGlobals = {
currentActivitySeq: 'readonly',
forgetActivitySeq: 'readonly',
purgeActivityFor: 'readonly',
dropLocalPtySession: 'readonly',
pruneRemoteActivityTimers: 'readonly',
// public/session-activity.js's persisted per-session state — see .ai/contexts/session-state.md ("The local-pty adapter")
localPtyState: 'readonly',
Expand Down
12 changes: 9 additions & 3 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
}

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

Check warning on line 71 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 71 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, refusedScheduleBinds, resolveScheduleSandbox, scheduleRegistry } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { SETTING_DEFAULTS } = require('./public/setting-defaults');
Expand Down Expand Up @@ -478,8 +478,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 481 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 481 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 482 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, isIndexingFinished } = sessionCache;
const { resolveJsonlPath, enumerateSessionFiles, readSubagentMeta } = require('./read-session-file');

Expand Down Expand Up @@ -2198,7 +2198,11 @@
}

// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach")
let ptyGenerationCounter = 0;

// see .ai/contexts/session-state.md ("A session main drops")
function wireSessionPty(session, sessionId, ptyProcess) {
session.generation = ++ptyGenerationCounter;
ptyProcess.onData(data => {
const currentId = session.realSessionId || sessionId;

Expand Down Expand Up @@ -2301,12 +2305,12 @@
const realId = session.realSessionId || sessionId;
if (TRACE.on) trace('pty.exit', realId, { exitCode, signal: exitSignal, stopped, alsoUnder: realId !== sessionId ? sessionId : null, wasBusy: !!session._cliBusy, sent: !!(mainWindow && !mainWindow.isDestroyed()) });
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('process-exited', realId, exitCode, exitSignal, stopped);
mainWindow.webContents.send('process-exited', realId, exitCode, exitSignal, stopped, session.generation);
// If a fork transition re-keyed this session under realId but the PTY
// exited before transition detection ran, also notify the renderer for
// the original sessionId so it doesn't stay stuck as "Running".
if (realId !== sessionId && activeSessions.has(sessionId)) {
mainWindow.webContents.send('process-exited', sessionId, exitCode, exitSignal, stopped);
mainWindow.webContents.send('process-exited', sessionId, exitCode, exitSignal, stopped, session.generation);
}
}
activeSessions.delete(realId);
Expand Down Expand Up @@ -2347,6 +2351,7 @@
ok: true, reattached: true, sandbox: !!session.sandbox,
mcpState: session.mcpError ? 'failed' : getMcpState(session.realSessionId || sessionId),
mcpError: session.mcpError || null,
generation: session.generation,
};
}

Expand Down Expand Up @@ -2381,7 +2386,7 @@
};
activeSessions.set(sessionId, remoteSession);
wireSessionPty(remoteSession, sessionId, attachResult.ptyProcess);
return { ok: true, reattached: false, remote: true, sandbox: false };
return { ok: true, reattached: false, remote: true, sandbox: false, generation: remoteSession.generation };
}
}

Expand Down Expand Up @@ -2441,7 +2446,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 2449 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 Expand Up @@ -2690,6 +2695,7 @@
ok: true, reattached: false, sandbox: !!sessionOptions?.sandbox,
mcpState: mcpError ? 'failed' : getMcpState(sessionId),
mcpError,
generation: session.generation,
};
});

Expand Down
2 changes: 1 addition & 1 deletion preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('session-detected', (_event, tempId, realId) => callback(tempId, realId));
},
onProcessExited: (callback) => {
ipcRenderer.on('process-exited', (_event, sessionId, exitCode, signal, stopped) => callback(sessionId, exitCode, signal, stopped));
ipcRenderer.on('process-exited', (_event, sessionId, exitCode, signal, stopped, generation) => callback(sessionId, exitCode, signal, stopped, generation));
},
onTerminalNotification: (callback) => {
ipcRenderer.on('terminal-notification', (_event, sessionId, message) => callback(sessionId, message));
Expand Down
54 changes: 49 additions & 5 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ let showTodayOnly = false;
let cachedProjects = [];
let cachedAllProjects = [];
let activePtyIds = new Set();
const ptyGenerations = new Map();
const pendingOpens = new Map();
let sortedOrder = []; // [{ projectPath, itemIds: [itemId, ...] }, ...] — single source of truth for sidebar order
let activeTab = 'sessions';
let visibleSessionCount = SETTING_DEFAULTS.visibleSessionCount;
Expand Down Expand Up @@ -498,7 +500,7 @@ window.api.onSessionForked((oldId, newId) => {
pollActiveSessions();
});

window.api.onProcessExited((sessionId, exitCode, signal, stopped) => {
function applyProcessExit(sessionId, exitCode, signal, stopped) {
if (window.ATRACE) window.atrace('recv.process-exited', sessionId, { exitCode, signal, stopped });
const entry = openSessions.get(sessionId);
const session = sessionMap.get(sessionId);
Expand All @@ -509,6 +511,10 @@ window.api.onProcessExited((sessionId, exitCode, signal, stopped) => {
return;
}
noteSessionExit(sessionId, exitCode, signal, stopped);
// see .ai/contexts/session-state.md ("A session main drops")
activePtyIds.delete(sessionId);
if (!sessionItemEl(sessionId)?.dataset.remoteAlias) dropLocalPtySession(sessionId, 'process-exited');
updateRunningIndicators();
const exit = lastSessionExit(sessionId);
if (entry) {
entry.closed = true;
Expand Down Expand Up @@ -562,6 +568,37 @@ window.api.onProcessExited((sessionId, exitCode, signal, stopped) => {
// Claude session exited → update persisted working set (entry.closed=true → excluded from set)
schedulePersistWorkingSet();
pollActiveSessions();
}

// see .ai/contexts/session-state.md ("A session main drops")
function handleProcessExited(sessionId, exitCode, signal, stopped, generation) {
const recorded = ptyGenerations.get(sessionId);
if (typeof generation === 'number' && typeof recorded === 'number' && generation < recorded) return;
const pending = pendingOpens.get(sessionId);
if (pending) {
pending.push({ args: [sessionId, exitCode, signal, stopped], generation });
return;
}
applyProcessExit(sessionId, exitCode, signal, stopped);
}

function beginPtyOpen(sessionId) {
pendingOpens.set(sessionId, []);
}

function settlePtyOpen(sessionId, result) {
const buffered = pendingOpens.get(sessionId) || [];
pendingOpens.delete(sessionId);
const generation = result && result.generation;
if (typeof generation === 'number') ptyGenerations.set(sessionId, generation);
for (const exit of buffered) {
if (typeof generation === 'number' && typeof exit.generation === 'number' && exit.generation < generation) continue;
applyProcessExit(...exit.args);
}
}

window.api.onProcessExited((sessionId, exitCode, signal, stopped, generation) => {
handleProcessExited(sessionId, exitCode, signal, stopped, generation);
});

// --- Terminal notifications (iTerm2 OSC 9 — "needs attention") ---
Expand Down Expand Up @@ -956,9 +993,7 @@ function updateRunningIndicators() {
// A stopped PTY can never emit subagent-completed (stop-session kills
// the process; detectSubagentTransitions skips exited sessions), so
// drop the live-subagent state now instead of waiting for the TTL.
clearActiveSubagentsFor(id);
// Runs after clearActiveSubagentsFor — see .ai/contexts/session-state.md ("The local-pty adapter")
purgeActivityFor(id, 'pty-gone');
dropLocalPtySession(id, 'pty-gone');
}
if (item.dataset.remoteAlias) setRemoteAttached(id, running);
// local-pty takes over a row the user just opened — see .ai/contexts/session-state.md
Expand All @@ -976,6 +1011,7 @@ function updateRunningIndicators() {
const dot = group.querySelector('.slug-group-dot');
if (dot) dot.classList.toggle('running', hasRunning);
});
renderDefaultStatus();
}

// Update grid card dots and status text — always run because sessionBusyState
Expand Down Expand Up @@ -1226,7 +1262,15 @@ async function openSession(session, customOptions, { automatic = false, live } =
const resumeOptions = customOptions
|| (session.type === 'terminal' ? { type: 'terminal' } : await resolveDefaultSessionOptions({ projectPath }));
forgetSessionExit(sessionId);
const result = await window.api.openTerminal(sessionId, projectPath, false, resumeOptions, entry.initialSize);
beginPtyOpen(sessionId);
let result;
try {
result = await window.api.openTerminal(sessionId, projectPath, false, resumeOptions, entry.initialSize);
} catch (err) {
settlePtyOpen(sessionId, null);
throw err;
}
settlePtyOpen(sessionId, result);
if (!result.ok) {
entry.terminal.write(`\r\nError: ${result.error}\r\n`);
entry.closed = true;
Expand Down
6 changes: 6 additions & 0 deletions public/session-activity.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ function purgeActivityFor(sessionId, via) {
applyStateClasses(sessionId, createSessionState('local-pty').snapshot());
}

// see .ai/contexts/session-state.md ("A session main drops")
function dropLocalPtySession(sessionId, via) {
clearActiveSubagentsFor(sessionId);
purgeActivityFor(sessionId, via);
}

// Central activity dispatcher. `via` is trace-only — see docs/activity-trace.md.
// opts.armReady=false: going idle must not arm response-ready — see .ai/contexts/session-cache.md ("Remote hosts — busy spinner")
function setActivity(sessionId, active, via, opts) {
Expand Down
Loading
Loading