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
4 changes: 3 additions & 1 deletion .ai/contexts/bg-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ attaches/stops/respawns/deletes/dispatches through the CLI. User doc:
9. `delete-session` asks `deleteSessionRefusal` (`delete-session-guard.js`)
first, and it fails closed. It refuses a session held by an open
terminal (by id or `realSessionId`), by a live job (`liveJobCheck`: the
job files, read directly so it works before the view was ever opened; the
job files, read directly so it works before the view was ever opened, a
job's session id taken from `linkScanPath`, else from its `sessionId` or
`resumeSessionId` field, which a job with no transcript yet carries; the
roster only supplies a session id a file lacks, or answers when `jobs/`
does not exist or a listed job's `state.json` is missing), or by another
process
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
- In a sandboxed session, a repository or a `.claude` folder below an Additional Directory that is a symbolic link now has its Git config, hooks and Claude settings read-only, like one below a plain directory. Before, they were writable from inside the sandbox. Such a directory holding a `.claude` that is itself a symbolic link, or a repository Git cannot read, now refuses to start the session (status 125), as a plain directory already did, instead of starting it unprotected. (#538)
- A double click on a session in the sidebar opens it once, instead of sometimes starting two terminals that resume the same conversation. (#539)
- Closing the window or quitting no longer leaves Switchboard running when its window has stopped responding: the window closes as before, and once the system reports it not responding, about 5 seconds after the close, or once you close it again, it is closed anyway. A window still busy at that point is closed too, and its last unsaved changes to the list of open sessions are lost; one that recovers sooner closes normally. (#540)
- Archiving or deleting a session no longer fails with "cannot tell whether a background job is running this session" while a background job that has not received its first prompt yet is live. (#553)

## v0.0.92 — 2026-10-10

Expand Down
7 changes: 6 additions & 1 deletion bg-agents-roster.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const SESSION_STATUSES = new Set(['busy', 'idle', 'waiting', 'shell']);
const JOB_ID_RE = /^[0-9a-f]{8}$/;
const JOB_ID_IN_TEXT_RE = /(?:^|[^0-9a-f])([0-9a-f]{8})(?![0-9a-f])/i;
const TRANSCRIPT_ID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

const str = (v) => (typeof v === 'string' && v ? v : null);
const num = (v) => (Number.isFinite(v) ? v : null);
Expand All @@ -17,6 +18,10 @@ function sessionIdFromLinkScanPath(p) {
return m ? m[1].toLowerCase() : null;
}

function sessionIdField(v) {
return typeof v === 'string' && SESSION_ID_RE.test(v) ? v.toLowerCase() : null;
}

function flagValue(flags, name) {
if (!Array.isArray(flags)) return null;
const i = flags.indexOf(name);
Expand Down Expand Up @@ -49,7 +54,7 @@ function parseJobState(text) {
agent: flagValue(raw.respawnFlags, '--agent'),
model: flagValue(raw.respawnFlags, '--model'),
name: flagValue(raw.respawnFlags, '--name'),
sessionId: sessionIdFromLinkScanPath(raw.linkScanPath),
sessionId: sessionIdFromLinkScanPath(raw.linkScanPath) || sessionIdField(raw.sessionId) || sessionIdField(raw.resumeSessionId),
transcriptPath: str(raw.linkScanPath),
bridgeSessionId: str(raw.bridgeSessionId),
};
Expand Down
10 changes: 10 additions & 0 deletions test/bg-agents-roster.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ test('parseJobState keeps the fields the view shows and derives agent/model/name
assert.equal(job.sessionId, 'bc3fd129-60bb-4bd2-8f38-63fecd1256e5');
});

test('parseJobState takes the session id from sessionId when the job has no transcript yet', () => {
const job = parseJobState(JSON.stringify({
state: 'working', needs: 'send a prompt to start', linkScanOffset: 0,
sessionId: 'FC9016D8-5781-452C-BB22-687C121E6C43', resumeSessionId: 'fc9016d8-5781-452c-bb22-687c121e6c43',
}));
assert.equal(job.sessionId, 'fc9016d8-5781-452c-bb22-687c121e6c43');
assert.equal(parseJobState(JSON.stringify({ state: 'working', resumeSessionId: 'fc9016d8-5781-452c-bb22-687c121e6c43' })).sessionId, 'fc9016d8-5781-452c-bb22-687c121e6c43');
assert.equal(parseJobState(JSON.stringify({ state: 'working', sessionId: 'not a session id' })).sessionId, null);
});

test('parseJobState: unknown state, missing output and garbage are tolerated', () => {
assert.equal(parseJobState(''), null);
assert.equal(parseJobState('[]'), null);
Expand Down
12 changes: 12 additions & 0 deletions test/bg-agents.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,18 @@ test('liveJobCheck reads the job files, takes the session id from the roster whe
} finally { rmTmp(dir); }
});

test('liveJobCheck: a live job with no transcript yet names its session through sessionId, so other sessions are not refused', async () => {
const dir = mkTmp();
try {
writeJob(dir, 'aaaaaaaa', { state: 'working', needs: 'send a prompt to start', sessionId: 'aaaaaaaa-5781-452c-bb22-687c121e6c43' });
boot(dir, { cli: fakeCli({ list: [] }) });
bgAgents.start();
await bgAgents.reconcile();
assert.deepEqual(bgAgents.liveJobCheck('11111111-2222-4333-8444-555555555555'), { known: true, job: null });
assert.deepEqual(bgAgents.liveJobCheck('aaaaaaaa-5781-452c-bb22-687c121e6c43'), { known: true, job: { id: 'aaaaaaaa', state: 'working' } });
} finally { rmTmp(dir); }
});

test('roster entries carry projectRoot and worktreeRoot: the pattern or the cwd at once, the resolved roots after a change event', async () => {
const dir = mkTmp();
try {
Expand Down
Loading