From 3377e29999990d6b27dba0d0ba43688875b7dbc1 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 14:32:59 +0200 Subject: [PATCH 1/2] fix(cli-session-state): rescan a session whose descriptor id differs only in case findSession, the descriptor-id change check and the rescan throttle compared session ids strictly, so a descriptor naming the session in a different case updated the status cache but never triggered the busy-to-idle rescan. They now use the same lowercased key as statusKey. Also, from the #374 review: tests for a live job of another session, for uppercase descriptor insertion and removal and for the bg-agent-live-job route; the folder-archive doc now matches the code when "Archive the sessions" is unchecked; runClaudeCommand's rationale comments are left to the context doc. Claude-Session: https://claude.ai/code/session_01TLG3EcjMdqPmYky3m2QrYA --- .ai/contexts/bg-agents.md | 5 ++++- cli-session-state.js | 8 ++++---- docs/background-agents.md | 8 +++++--- main.js | 5 +---- test/bg-agents-ipc.test.js | 13 ++++++++++++- test/cli-session-state.test.js | 29 +++++++++++++++++++++++++++++ test/delete-session-guard.test.js | 26 ++++++++++++++++++++++++++ 7 files changed, 81 insertions(+), 13 deletions(-) diff --git a/.ai/contexts/bg-agents.md b/.ai/contexts/bg-agents.md index 66425da0..748cf596 100644 --- a/.ai/contexts/bg-agents.md +++ b/.ai/contexts/bg-agents.md @@ -393,7 +393,10 @@ the client is killed. The call resolves on `close` so stdout is drained, or - Archiving or deleting a session that a live daemon job runs is refused by `stopBeforeArchive` (IPC `bg-agent-live-job`, the same `liveJobCheck`, failing closed) before anything is detached; a folder archive (#482) that - holds one archives nothing. Stop the job from the Agents view first. + holds one archives nothing. Stop the job from the Agents view first. With + "Archive the sessions" unchecked a folder archive never calls + `stopBeforeArchive`: it only hides the folder, archives and detaches + nothing, and the job stays in the Agents view. - A live job keeps the sidebar's Fork button: a fork reads the transcript and starts a new session, it never resumes the job. - A global WSL shell profile runs `claude` inside WSL, whose daemon and diff --git a/cli-session-state.js b/cli-session-state.js index 7d938dba..51adbdf8 100644 --- a/cli-session-state.js +++ b/cli-session-state.js @@ -202,7 +202,7 @@ function findSession(sessionId) { for (const [key, session] of activeSessions) { if (!session || session.exited || session.isPlainTerminal || !session.projectFolder) continue; const effectiveId = session.realSessionId || key; - if (effectiveId === sessionId) return { sessionId: effectiveId, session }; + if (statusKey(effectiveId) === statusKey(sessionId)) return { sessionId: effectiveId, session }; } return null; } @@ -222,7 +222,7 @@ function handleFile(name) { if (!state) return; const prev = known.get(name); - if (prev && prev.sessionId && prev.sessionId !== state.sessionId) forgetSession(prev.sessionId); + if (prev && prev.sessionId && statusKey(prev.sessionId) !== statusKey(state.sessionId)) forgetSession(prev.sessionId); known.set(name, { procStart: state.procStart, status: state.status, sessionId: state.sessionId }); if (isProcessAlive(state.pid)) { statusBySession.set(statusKey(state.sessionId), { status: state.status, statusUpdatedAt: state.statusUpdatedAt, pid: state.pid }); @@ -243,9 +243,9 @@ function handleFile(name) { } const now = Date.now(); - const last = lastRescanAt.get(match.sessionId) || 0; + const last = lastRescanAt.get(statusKey(match.sessionId)) || 0; if (now - last < MIN_RESCAN_INTERVAL_MS) return; - lastRescanAt.set(match.sessionId, now); + lastRescanAt.set(statusKey(match.sessionId), now); try { onIdle(match.sessionId, match.session); diff --git a/docs/background-agents.md b/docs/background-agents.md index 3e28dea3..1e02b153 100644 --- a/docs/background-agents.md +++ b/docs/background-agents.md @@ -132,9 +132,11 @@ the sandbox and without the pre-launch command. The dialog says so and Start stays disabled; start a session in that project instead. A session that a background job is still running cannot be archived or -deleted from the sidebar, nor can a folder that holds one: stop the job from -the Agents view first. A session live in another process cannot be deleted -either. When Switchboard cannot tell, for example because `~/.claude/jobs` +deleted from the sidebar: stop the job from the Agents view first. Archiving +a folder that holds one is refused when **Archive the sessions** is checked. +Unchecked, the folder is only hidden: no session is archived or detached, and +the job keeps running and stays in the Agents view. A session live in another +process cannot be deleted either. When Switchboard cannot tell, for example because `~/.claude/jobs` cannot be read, it refuses and says why. ## When the daemon does not answer diff --git a/main.js b/main.js index 98d463b4..3c391b1e 100644 --- a/main.js +++ b/main.js @@ -2440,13 +2440,12 @@ function killProcessTree(child) { } catch { try { child.kill('SIGKILL'); } catch {} } } -// Run `claude ` to completion through the login shell -- see .ai/contexts/bg-agents.md +// Run `claude ` to completion through the login shell -- see .ai/contexts/bg-agents.md ("Running the CLI") function runClaudeCommand(claudeArgv, { cwd, timeout }) { return new Promise((resolve) => { const globalSettings = getSetting('global') || {}; const profile = resolveShell(globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile); const shell = profile.path; - // cmd.exe and PowerShell mangle quotes, `&` and newlines in a prompt: run claude itself const direct = isWindows && !isWslShell(shell) && !/bash|zsh|fish|^sh$|^nu$/.test(path.basename(shell, path.extname(shell)).toLowerCase()); const childEnv = { ...cleanPtyEnv, FORCE_COLOR: '0' }; let program = shell; @@ -2459,7 +2458,6 @@ function runClaudeCommand(claudeArgv, { cwd, timeout }) { } else { args = shellArgs(shell, 'claude ' + quoteArgvForShell(shell, claudeArgv), profile.args || []); } - // a timed-out --bg may have started the daemon: kill the client only, never its group const killTree = claudeArgv[0] !== '--bg'; let stdout = ''; let stderr = ''; @@ -2488,7 +2486,6 @@ function runClaudeCommand(claudeArgv, { cwd, timeout }) { child.stderr.on('data', (d) => { stderr += d.toString(); }); child.on('error', (err) => { clearTimeout(timer); finish(null, err); }); child.on('close', (code) => { clearTimeout(timer); finish(code); }); - // a daemon that inherited the pipes keeps `close` from firing: settle shortly after the client exits child.on('exit', (code) => { const grace = setTimeout(() => { clearTimeout(timer); finish(code); }, 1000); if (typeof grace.unref === 'function') grace.unref(); diff --git a/test/bg-agents-ipc.test.js b/test/bg-agents-ipc.test.js index 82a2be50..694d23f9 100644 --- a/test/bg-agents-ipc.test.js +++ b/test/bg-agents-ipc.test.js @@ -1,4 +1,4 @@ -// test/bg-agents-ipc.test.js — the three handlers and the push. See .ai/contexts/bg-agents.md. +// test/bg-agents-ipc.test.js — the four handlers and the push. See .ai/contexts/bg-agents.md. 'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); @@ -18,6 +18,7 @@ function fakeBgAgents() { reconcile: async () => { calls.push('reconcile'); return { roster: [], daemonReachable: true }; }, runVerb: async (verb, id) => { calls.push(['verb', verb, id]); return { ok: true }; }, dispatch: async (fields) => { calls.push(['dispatch', fields]); return { ok: true, id: 'aaaaaaaa' }; }, + liveJobCheck: (sessionId) => { calls.push(['liveJobCheck', sessionId]); return { known: true, job: null }; }, onChange: (l) => { listener = l; return () => { listener = null; }; }, fire: (snap) => listener && listener(snap), }; @@ -65,3 +66,13 @@ test('get-bg-agents restores the push after stop() cleared it, without ever doub for (const l of listeners) l({ roster: [], daemonReachable: true }); assert.deepEqual(sent, [['bg-agents-changed', { roster: [], daemonReachable: true }]]); }); + +test('bg-agent-live-job answers liveJobCheck for the id, coerced to a string', async () => { + const { handlers, ipcMain } = fakeIpc(); + const bg = fakeBgAgents(); + init({ ipcMain, bgAgents: bg, getMainWindow: () => null, log: { warn() {} } }); + assert.deepEqual(await handlers.get('bg-agent-live-job')({}, 'S-1'), { known: true, job: null }); + await handlers.get('bg-agent-live-job')({}, undefined); + await handlers.get('bg-agent-live-job')({}, { toString: () => 'x' }); + assert.deepEqual(bg.calls, [['liveJobCheck', 'S-1'], ['liveJobCheck', ''], ['liveJobCheck', 'x']]); +}); diff --git a/test/cli-session-state.test.js b/test/cli-session-state.test.js index 1d8b9e0f..24543134 100644 --- a/test/cli-session-state.test.js +++ b/test/cli-session-state.test.js @@ -91,6 +91,35 @@ test('a busy → idle transition rescans the matching session immediately', asyn } }); +test('a descriptor whose id differs only in case still rescans the app session, under the app id', async () => { + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'busy', sessionId: 'SESS-1' }); + const { rescans } = boot(dir, oneSession()); + writeState(dir, 4242, { status: 'idle', sessionId: 'SESS-1' }); + await waitFor(() => rescans.length === 1); + assert.equal(rescans[0].sessionId, 'sess-1'); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('an uppercase descriptor is found under the lowercase id, and forgotten when its file goes', async () => { + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'busy', sessionId: 'SESS-1' }); + boot(dir, oneSession()); + writeState(dir, 4242, { status: 'idle', sessionId: 'SESS-1' }); + await waitFor(() => cliSessionState.getStatus('sess-1')?.status === 'idle'); + fs.rmSync(path.join(dir, '4242.json')); + await waitFor(() => cliSessionState.getStatus('sess-1') === undefined); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('waiting and shell never rescan — only idle does', async () => { // The CLI reports four statuses. Only idle means "the turn is over"; waiting // is a permission prompt and shell is a suspended session, both of which can diff --git a/test/delete-session-guard.test.js b/test/delete-session-guard.test.js index cb6d5dcc..fede913a 100644 --- a/test/delete-session-guard.test.js +++ b/test/delete-session-guard.test.js @@ -159,3 +159,29 @@ test('a cached live job whose state.json is missing still refuses the delete', a bgAgents.stop(); } }); + +test('a live job of another session does not refuse the delete, from the file or from the cache', async () => { + const OTHER = 'ffffffff-4e5f-4a6b-8c7d-9e0f1a2b3c4d'; + const jobsDir = path.join(root, 'jobs'); + fs.mkdirSync(path.join(jobsDir, 'aaaaaaaa'), { recursive: true }); + fs.writeFileSync(path.join(jobsDir, 'aaaaaaaa', 'state.json'), JSON.stringify({ + state: 'working', linkScanPath: `/home/u/.claude/projects/-w/${OTHER}.jsonl`, + })); + bgAgents.init({ + jobsDir, log: silentLog, cliSessionState, homeDir: os.tmpdir(), resolveProjectRoots: async () => null, + runClaude: async (argv) => (argv[0] === 'agents' + ? { code: 0, stdout: JSON.stringify([ + { id: 'aaaaaaaa', sessionId: OTHER, kind: 'background', state: 'working', cwd: root }, + { id: 'bbbbbbbb', sessionId: OTHER, kind: 'background', state: 'blocked', cwd: root }, + ]), stderr: '' } + : { code: 1, stdout: '', stderr: '' }), + }); + cliSessionState.init({ + dir: path.join(root, 'sessions'), activeSessions: new Map(), log: silentLog, onIdle: () => {}, + isProcessAlive: () => true, readProcStart: () => '111', readParentPid: () => 1, ownPid: 99999, platform: 'linux', + }); + bgAgents.start(); + await bgAgents.reconcile(); + assert.equal(await refusal(SID), null); + assert.match(await refusal(OTHER.toUpperCase()), /background job aaaaaaaa is still running/); +}); From 103c280caa0620efe99bf99f2af1a827da264658 Mon Sep 17 00:00:00 2001 From: pjay Date: Wed, 7 Oct 2026 14:38:22 +0200 Subject: [PATCH 2/2] docs(changelog): note the mixed-case rescan fix Claude-Session: https://claude.ai/code/session_01TLG3EcjMdqPmYky3m2QrYA --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b7b2165..333bbdf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ### Changed - Markdown files open formatted in Touched, with a toggle back to the source that is remembered. (#472) ### Fixed +- When the Claude CLI reports a session's id in a different letter case, the subagents that session started or finished during a turn now show up when the turn ends. (#487) - Worktree folders from another host, or whose repository is not listed, show in the sidebar instead of being nested under the wrong folder or not shown at all. The worktrees of a repository hidden with Hide Project stay hidden. (#473) - 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)