Skip to content
Open
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
44 changes: 44 additions & 0 deletions .ai/contexts/cli-session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,50 @@ The sidebar has no dedicated marker for such a session. Once the watcher has
seen its state file, `getStatus()` gives it the same state+age line as any live
session (see the section above).

## Owner of a /clear transcript

`/clear` does not continue the open transcript: the CLI opens a **new jsonl
under a new session id** in the same project folder, and writes into it only
the bookkeeping of the command (a `<local-command-caveat>` record, then the
`<command-name>/clear</command-name>` record). Nothing in that file names the
session it replaced — no `forkedFrom`, and the `sessionId` on every record is
the new one. `detectSessionTransitions()` (`session-transitions.js`) used to
match new files by those two fields only, so after a `/clear` the open
terminal stayed on the old row and the new conversation, once its first prompt
made it indexable, showed up as a second, unattached row.

The state file is what links the two: the CLI rewrites
`~/.claude/sessions/<pid>.json` with the new `sessionId` on `/clear` (checked
2026-10-05 on CLI 2.1.28x: live state files whose `sessionId` names a
transcript that starts with the `/clear` record). `clearOwner(newId, ptyPid)`
finds the live pid whose state file names `newId` and walks its parent chain
(`/proc/<pid>/stat`) up to the PTY's pid. Verdicts:

- `mine` — the CLI runs under this PTY: re-key, exactly like a fork
(`session-forked` to the renderer).
- `other` — another process (a CLI started outside Switchboard, another tab in
the same folder): not this session's file.
- `pending` — no live state file names `newId` yet (the jsonl can land before
the state file is rewritten): the file stays out of `knownJsonlFiles` and is
rechecked on the next flush, for at most 60 s from its mtime.
- `unknown` — the pid is found but its parent cannot be read (no `/proc`:
macOS, Windows). Matched only when this is the one live Claude PTY in the
folder, since `cwd` cannot tell two sessions apart (see "Matching a state
file" above); with two or more, nothing is re-keyed, the file is recorded
and never rechecked — retrying it would hand it to whichever session
outlives the others, not to its owner.

`readNewSessionSignals()` recognises the file by its first user record that is
not local-command bookkeeping, through `classifyUserText()` — the caveat record
comes first and must be skipped, not taken as the file's first turn.

The renderer re-keys as for a fork, and when the old id was not a pending
(transcript-less) row it adds one for the new id titled "New session": the
`/clear` transcript is not indexed until its first prompt (see
session-cache.md), so without it the open terminal would have no row at all
until then. The old conversation stays in the list as an ordinary,
resumable session — it is a real transcript on disk.

## Canary tests

`test/canary-*.test.js` is a convention this module introduces. A canary
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
- `/clear` in a session keeps the open terminal on its sidebar row, which now follows the new conversation, instead of listing that conversation as a separate session. The cleared conversation stays in the list as a past session.
- Remote triggers refuse commands containing invisible format characters, default-ignorable characters or braille blanks, including joined emoji, emoji with variation selectors (such as hearts), soft hyphens and right-to-left marks. Fullwidth slash, exclamation and number-sign prefixes are refused too. (#440)

## v0.0.89 — 2026-10-04
Expand Down
27 changes: 27 additions & 0 deletions cli-session-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,32 @@ async function findLiveProcess(sessionId, { exclude = () => false } = {}) {
return (await scanLiveProcesses(new Set([sessionId]), exclude)).get(sessionId) || null;
}

// see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript")
function clearOwner(sessionId, ptyPid) {
if (typeof sessionId !== 'string' || !sessionId || !Number.isInteger(ptyPid)) return 'pending';
let names;
try { names = fs.readdirSync(dir); } catch { return 'pending'; }
let pid = null;
for (const name of names) {
if (!STATE_FILE_RE.test(name)) continue;
let raw;
try { raw = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')); } catch { continue; }
if (!raw || raw.sessionId !== sessionId || !Number.isInteger(raw.pid) || raw.pid <= 0) continue;
if (!isProcessAlive(raw.pid)) continue;
pid = raw.pid;
break;
}
if (pid == null) return 'pending';
let current = pid;
for (let depth = 0; depth < 64 && current && current > 1; depth++) {
if (current === ptyPid) return 'mine';
const parent = readParentPid(current);
if (parent == null) return depth === 0 ? 'unknown' : 'other';
current = parent;
}
return 'other';
}

function ownProcessFilter(ptyPids) {
const own = new Set(ptyPids());
return (pid) => own.has(pid) || descendsFromThisProcess(pid);
Expand Down Expand Up @@ -382,6 +408,7 @@ module.exports = {
findLiveProcess,
liveElsewhere,
liveElsewhereMany,
clearOwner,
MAX_LIVE_QUERY_IDS,
MAX_PROBE_PIDS,
probeProcStartWindows,
Expand Down
1 change: 1 addition & 0 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, scheduleBindRefusals, resolveScheduleSandbox, scheduleRegistry, initialScheduleProjects } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { SETTING_DEFAULTS } = require('./public/setting-defaults');
Expand Down Expand Up @@ -484,8 +484,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 487 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 487 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 488 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, readSubagentMeta } = require('./read-session-file');

Expand Down Expand Up @@ -2520,7 +2520,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 2523 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 @@ -2958,6 +2958,7 @@
sessionTransitions.init({
PROJECTS_DIR, activeSessions, getMainWindow: () => mainWindow, log, rekeyMcpServer,
rekeyActivity: (fromId, toId) => activityReporter.rekey(fromId, toId),
clearOwner: (sessionId, ptyPid) => cliSessionState.clearOwner(sessionId, ptyPid),
});
const { detectSessionTransitions } = sessionTransitions;

Expand Down
5 changes: 5 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,11 @@ window.api.onSessionForked((oldId, newId) => {
if (pendingEntry) {
pendingEntry.sessionId = newId;
pendingSessions.set(newId, pendingEntry);
} else if (entry.session.projectPath) {
// see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript")
const projectPath = entry.session.projectPath;
const session = { ...entry.session, sessionId: newId, summary: 'New session', firstPrompt: '', name: null, messageCount: 0 };
pendingSessions.set(newId, { session, projectPath, folder: encodeProjectPath(projectPath) });
}
sessionMap.delete(oldId);
sessionMap.set(newId, entry.session);
Expand Down
44 changes: 38 additions & 6 deletions session-transitions.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
const path = require('path');
const fs = require('fs');
const { readSubagentMeta } = require('./read-session-file');
const { readSubagentMeta, classifyUserText } = require('./read-session-file');
const { enabled: TRACE, trace } = require('./activity-trace');
const { SUBAGENT_LIVE_TTL_MS } = require('./public/subagent-timing');

/**
* Fork detection for active PTY sessions.
* Call init(ctx) once with shared context.
*/
let PROJECTS_DIR, activeSessions, getMainWindow, log, rekeyMcpServer, rekeyActivity;
let PROJECTS_DIR, activeSessions, getMainWindow, log, rekeyMcpServer, rekeyActivity, clearOwner;

function init(ctx) {
PROJECTS_DIR = ctx.PROJECTS_DIR;
Expand All @@ -17,6 +17,7 @@ function init(ctx) {
log = ctx.log;
rekeyMcpServer = ctx.rekeyMcpServer;
rekeyActivity = ctx.rekeyActivity || (() => {});
clearOwner = ctx.clearOwner || (() => 'pending');
}

// --- Subagent spawn / completion detection ---
Expand Down Expand Up @@ -309,6 +310,7 @@ function readNewSessionSignals(filePath) {
let slug = null;
let parentSessionId = null;
let hasSnapshots = false;
let cleared = false;
for (const line of lines) {
const entry = JSON.parse(line);
// Skip snapshot lines — they carry no fork/session signals
Expand All @@ -317,16 +319,33 @@ function readNewSessionSignals(filePath) {
if (entry.slug && !slug) slug = entry.slug;
// --fork-session copies messages with original sessionId
if (entry.sessionId && !parentSessionId) parentSessionId = entry.sessionId;
if (entry.type === 'user') {
const text = typeof entry.message?.content === 'string' ? entry.message.content : '';
const kind = classifyUserText(text);
// see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript")
if (kind.kind === 'skip') continue;
cleared = kind.kind === 'command' && kind.text === '/clear';
}
// Stop after finding a user or assistant message
if (entry.type === 'user' || entry.type === 'assistant') break;
}
return { forkedFrom, slug, parentSessionId, hasSnapshots };
return { forkedFrom, slug, parentSessionId, hasSnapshots, cleared };
} catch {
return { forkedFrom: null, slug: null, parentSessionId: null, hasSnapshots: false };
return { forkedFrom: null, slug: null, parentSessionId: null, hasSnapshots: false, cleared: false };
}
}

const CLEAR_OWNER_WAIT_MS = 60000;

function isSoleClaudeIn(folder) {
let count = 0;
for (const session of activeSessions.values()) {
if (!session.exited && !session.isPlainTerminal && session.projectFolder === folder) count++;
}
return count === 1;
}

/** Detect fork transitions for active PTY sessions in a folder */
/** Detect fork and /clear transitions for active PTY sessions in a folder */
function detectSessionTransitions(folder) {
const folderPath = path.join(PROJECTS_DIR, folder);
let currentFiles;
Expand Down Expand Up @@ -405,12 +424,25 @@ function detectSessionTransitions(folder) {
matched = true;
}

const awaitingFork = Boolean(session.forkFrom && !session.realSessionId);
if (!matched && signals.cleared && !awaitingFork && newId !== (session.realSessionId || sessionId)) {
const owner = clearOwner(newId, session.pty && session.pty.pid);
if (owner === 'mine' || (owner === 'unknown' && isSoleClaudeIn(folder))) {
matched = true;
} else if (owner === 'pending') {
let fresh = false;
try { fresh = Date.now() - fs.statSync(newFilePath).mtimeMs < CLEAR_OWNER_WAIT_MS; } catch {}
if (fresh) emptyFiles.add(newFile);
}
log.info(`[detect] session=${sessionId} /clear file=${newId} owner=${owner} matched=${matched}`);
}

if (session.forkFrom && !matched) {
log.info(`[detect] session=${sessionId} NO MATCH for newFile=${newId} forkFrom=${session.forkFrom} parentSessionId=${signals.parentSessionId||'null'} forkedFrom=${signals.forkedFrom||'null'}`);
}

if (matched) {
log.info(`[session-transition] ${sessionId} → ${newId} (fork)`);
log.info(`[session-transition] ${sessionId} → ${newId} (${signals.cleared && !awaitingFork ? 'clear' : 'fork'})`);
session.knownJsonlFiles = new Set(currentFiles);
session.realSessionId = newId;
// Subagent scanning follows realSessionId into a different directory —
Expand Down
28 changes: 28 additions & 0 deletions test/cli-session-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -427,3 +427,31 @@ test('getStatus keeps returning the cached status within the 5s probe throttle e
fs.rmSync(dir, { recursive: true, force: true });
}
});

// see .ai/contexts/cli-session-state.md ("Owner of a /clear transcript")
test('clearOwner: walks the state file pid up to the PTY pid', () => {
const dir = mkTmp();
try {
const parents = { 300: 200, 200: 100, 100: 1, 400: 1 };
cliSessionState.init({
dir, activeSessions: new Map(), log: silentLog,
isProcessAlive: (pid) => pid !== 500,
readParentPid: (pid) => parents[pid] ?? null,
});
writeState(dir, 300, { sessionId: 'cleared', status: 'idle' });
writeState(dir, 400, { sessionId: 'elsewhere', status: 'idle' });
writeState(dir, 500, { sessionId: 'dead', status: 'idle' });
writeState(dir, 600, { sessionId: 'no-proc', status: 'idle' });

assert.equal(cliSessionState.clearOwner('cleared', 100), 'mine');
assert.equal(cliSessionState.clearOwner('cleared', 300), 'mine', 'the PTY runs claude itself');
assert.equal(cliSessionState.clearOwner('elsewhere', 100), 'other');
assert.equal(cliSessionState.clearOwner('dead', 100), 'pending', 'a dead pid is no owner');
assert.equal(cliSessionState.clearOwner('absent', 100), 'pending');
assert.equal(cliSessionState.clearOwner('no-proc', 100), 'unknown', 'parent unreadable');
assert.equal(cliSessionState.clearOwner('cleared', undefined), 'pending', 'no PTY pid');
} finally {
cliSessionState.stop();
fs.rmSync(dir, { recursive: true, force: true });
}
});
Loading
Loading