diff --git a/.ai/contexts/bg-agents.md b/.ai/contexts/bg-agents.md index 66425da0b..748cf5967 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/CHANGELOG.md b/CHANGELOG.md index 9b7b21658..333bbdf73 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) diff --git a/cli-session-state.js b/cli-session-state.js index 7d938dba4..51adbdf8a 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 3e28dea3f..1e02b1532 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 98d463b42..3c391b1ea 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 82a2be507..694d23f93 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 1d8b9e0f6..245431346 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 cb6d5dcc6..fede913a5 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/); +});