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
5 changes: 4 additions & 1 deletion .ai/contexts/bg-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 4 additions & 4 deletions cli-session-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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 });
Expand All @@ -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);
Expand Down
8 changes: 5 additions & 3 deletions docs/background-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 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

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler, scheduleBindRefusals, resolveScheduleSandbox, scheduleRegistry, initialScheduleProjects, scanSchedules, setScheduleEnabled } = require('./schedule-runner');
const { applyAndPersistArchived, clearArchivedEntry, archivePlanForGroups, validArchiveGroups, archiveProjectFolders, reenableOfferedSchedules, dismissReenableOffer } = require('./archived-projects');
const { encodeProjectPath } = require('./encode-project-path');
Expand Down Expand Up @@ -489,8 +489,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 492 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 492 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 493 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 @@ -2440,13 +2440,12 @@
} catch { try { child.kill('SIGKILL'); } catch {} }
}

// Run `claude <argv>` to completion through the login shell -- see .ai/contexts/bg-agents.md
// Run `claude <argv>` 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;
Expand All @@ -2459,7 +2458,6 @@
} 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 = '';
Expand Down Expand Up @@ -2488,7 +2486,6 @@
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();
Expand Down Expand Up @@ -2645,7 +2642,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 2645 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
13 changes: 12 additions & 1 deletion test/bg-agents-ipc.test.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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),
};
Expand Down Expand Up @@ -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']]);
});
29 changes: 29 additions & 0 deletions test/cli-session-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions test/delete-session-guard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Loading