Environment
- OS: macOS (Darwin 25.3.0)
- Claudian: v1.3.72
- Claude Agent SDK: ^0.2.76
- Model: Claude Opus 4.6 (1M context)
Description
When switching between Claudian tabs while an agent is actively streaming/working in one tab, the active conversation gets interrupted. The agent stops mid-task, and when switching back, the user must manually send a message (e.g., "继续") to prompt it to continue — at which point the agent has lost its working context and has to re-orient from scratch.
Steps to Reproduce
- Open Claudian in Obsidian
- In Tab A, start a complex task (e.g., a codebase seminar that spawns subagents)
- While Tab A's agent is actively working (streaming, running tool calls, etc.), switch to Tab B
- Switch back to Tab A
Expected Behavior
Each tab's conversation should run independently. Switching to Tab B should not affect Tab A's active agent/streaming.
Actual Behavior
- Tab A's agent is interrupted upon switching away
- The UI shows
(background task completed) — the auto-turn callback fires with tool activity but no text (renderAutoTriggeredTurn at Tab.ts:1224)
- The agent does not resume on its own — the user must manually send a message to prompt it to continue
- After receiving the manual prompt, the agent says "Let me orient myself and find where we left off" — it has lost its working context
Code Analysis
I traced through the TypeScript source (src/) to identify the root cause. Here's what I found and what I ruled out.
What I Ruled Out: switchToTab → setSessionId → closePersistentQuery
My initial hypothesis was that switchToTab() (TabManager.ts:148) calls tab.service.setSessionId() (line 192), which triggers closePersistentQuery('session switch') (ClaudianService.ts:1481). However, after deeper investigation, this should only affect the target tab's own service, because:
- Each tab creates its own
ClaudianService instance (new ClaudianService() at Tab.ts:252)
- Each service spawns its own CLI process via
agentQuery() → createCustomSpawnFunction() → child_process.spawn() (customSpawn.ts:33)
- Each service has its own
queryAbortController, persistentQuery, and messageChannel
switchToTab() only calls setSessionId() on the target tab's service, never the source tab's
So Tab B's session sync should be completely isolated from Tab A's active streaming.
The (background task completed) Symptom
This message comes from renderAutoTriggeredTurn() (Tab.ts:1203-1238). It fires when:
- A
turnComplete message arrives in the response consumer loop (ClaudianService.ts:727)
- No handler is registered (
handler is null at line 656)
- Buffered chunks have tool activity but no text content (Tab.ts:1222-1224)
This means Tab A's handler was already unregistered when the SDK delivered the turn completion. The handler's onDone() was called prematurely, causing state.done = true (line 1018), which broke the while (!state.done) yield loop (line 1057). Subsequent chunks from the CLI were routed to the auto-turn buffer instead.
Possible Root Causes (Not Yet Confirmed)
Since the code-level isolation appears correct, the actual root cause may be in a layer I couldn't fully inspect:
1. Claude Agent SDK internal shared state
The agentQuery() function from @anthropic-ai/claude-agent-sdk may use shared resources (session files, lock files, IPC channels) between multiple Query instances. When Tab B's service calls persistentQuery.interrupt(), it may affect Tab A's process through a shared session directory or similar mechanism.
2. Electron renderer throttling
When the Claudian view becomes non-foreground (switching Obsidian leaves, not Claudian tabs), Electron/Chromium may throttle the renderer process. If the response consumer loop's for await (line 564) depends on timely event loop processing, throttling could cause the stdio pipe to stall or the CLI process to timeout waiting for input.
3. Race condition in handler lifecycle
If isTurnCompleteMessage() fires at just the right moment during a tab switch, the handler could be unregistered before all chunks are delivered. The handler lifecycle assumes single-turn processing (line 646-648: "Only one handler exists at a time because MessageChannel enforces single-turn processing"), but subagent completion may create edge cases.
4. AbortSignal propagation
customSpawn.ts:44 registers signal.addEventListener('abort', () => child.kill()). If the AbortSignal from Tab B's queryAbortController somehow affects Tab A's child process (e.g., through Electron's cross-realm AbortSignal issues noted at customSpawn.ts:30-32), it could kill Tab A's CLI process.
Suggested Debugging Steps
-
Add logging to closePersistentQuery() to confirm which service instance is being closed:
closePersistentQuery(reason?: string) {
console.log(`[closePQ] reason=${reason}, handlerCount=${this.responseHandlers.length}, consumerRunning=${this.responseConsumerRunning}`);
}
-
Add logging to routeMessage() when a turn completes with no handler:
if (isTurnCompleteMessage(message)) {
console.log(`[turnComplete] hasHandler=${!!handler}, bufferLen=${this._autoTurnBuffer.length}`);
}
-
Log when onDone() is called on a handler to find the premature termination:
onDone: () => {
console.log(`[handler.onDone] id=${handlerId}`, new Error().stack);
}
-
Check if multiple tab services' CLI processes share a PID group or session directory.
Key Files
| Component |
File |
Lines |
switchToTab() |
src/features/chat/tabs/TabManager.ts |
148-203 |
setSessionId() |
src/core/agent/ClaudianService.ts |
1475-1495 |
closePersistentQuery() |
src/core/agent/ClaudianService.ts |
380-433 |
| Response consumer loop |
src/core/agent/ClaudianService.ts |
550-632 |
| Handler yield loop |
src/core/agent/ClaudianService.ts |
1057-1068 |
renderAutoTriggeredTurn() |
src/features/chat/tabs/Tab.ts |
1203-1238 |
| Custom spawn (abort → kill) |
src/core/agent/customSpawn.ts |
40-46 |
deactivateTab() / activateTab() |
src/features/chat/tabs/Tab.ts |
1043-1060 |
Related Issues
Note
This is particularly disruptive for long-running agent tasks (codebase exploration, multi-file operations) that can take 5-10+ minutes. Users naturally want to use other tabs while waiting, but doing so kills the running task.
Environment
Description
When switching between Claudian tabs while an agent is actively streaming/working in one tab, the active conversation gets interrupted. The agent stops mid-task, and when switching back, the user must manually send a message (e.g., "继续") to prompt it to continue — at which point the agent has lost its working context and has to re-orient from scratch.
Steps to Reproduce
Expected Behavior
Each tab's conversation should run independently. Switching to Tab B should not affect Tab A's active agent/streaming.
Actual Behavior
(background task completed)— the auto-turn callback fires with tool activity but no text (renderAutoTriggeredTurnat Tab.ts:1224)Code Analysis
I traced through the TypeScript source (
src/) to identify the root cause. Here's what I found and what I ruled out.What I Ruled Out:
switchToTab → setSessionId → closePersistentQueryMy initial hypothesis was that
switchToTab()(TabManager.ts:148) callstab.service.setSessionId()(line 192), which triggersclosePersistentQuery('session switch')(ClaudianService.ts:1481). However, after deeper investigation, this should only affect the target tab's own service, because:ClaudianServiceinstance (new ClaudianService()at Tab.ts:252)agentQuery()→createCustomSpawnFunction()→child_process.spawn()(customSpawn.ts:33)queryAbortController,persistentQuery, andmessageChannelswitchToTab()only callssetSessionId()on the target tab's service, never the source tab'sSo Tab B's session sync should be completely isolated from Tab A's active streaming.
The
(background task completed)SymptomThis message comes from
renderAutoTriggeredTurn()(Tab.ts:1203-1238). It fires when:turnCompletemessage arrives in the response consumer loop (ClaudianService.ts:727)handleris null at line 656)This means Tab A's handler was already unregistered when the SDK delivered the turn completion. The handler's
onDone()was called prematurely, causingstate.done = true(line 1018), which broke thewhile (!state.done)yield loop (line 1057). Subsequent chunks from the CLI were routed to the auto-turn buffer instead.Possible Root Causes (Not Yet Confirmed)
Since the code-level isolation appears correct, the actual root cause may be in a layer I couldn't fully inspect:
1. Claude Agent SDK internal shared state
The
agentQuery()function from@anthropic-ai/claude-agent-sdkmay use shared resources (session files, lock files, IPC channels) between multipleQueryinstances. When Tab B's service callspersistentQuery.interrupt(), it may affect Tab A's process through a shared session directory or similar mechanism.2. Electron renderer throttling
When the Claudian view becomes non-foreground (switching Obsidian leaves, not Claudian tabs), Electron/Chromium may throttle the renderer process. If the response consumer loop's
for await(line 564) depends on timely event loop processing, throttling could cause the stdio pipe to stall or the CLI process to timeout waiting for input.3. Race condition in handler lifecycle
If
isTurnCompleteMessage()fires at just the right moment during a tab switch, the handler could be unregistered before all chunks are delivered. The handler lifecycle assumes single-turn processing (line 646-648: "Only one handler exists at a time because MessageChannel enforces single-turn processing"), but subagent completion may create edge cases.4. AbortSignal propagation
customSpawn.ts:44registerssignal.addEventListener('abort', () => child.kill()). If the AbortSignal from Tab B'squeryAbortControllersomehow affects Tab A's child process (e.g., through Electron's cross-realm AbortSignal issues noted at customSpawn.ts:30-32), it could kill Tab A's CLI process.Suggested Debugging Steps
Add logging to
closePersistentQuery()to confirm which service instance is being closed:Add logging to
routeMessage()when a turn completes with no handler:Log when
onDone()is called on a handler to find the premature termination:Check if multiple tab services' CLI processes share a PID group or session directory.
Key Files
switchToTab()src/features/chat/tabs/TabManager.tssetSessionId()src/core/agent/ClaudianService.tsclosePersistentQuery()src/core/agent/ClaudianService.tssrc/core/agent/ClaudianService.tssrc/core/agent/ClaudianService.tsrenderAutoTriggeredTurn()src/features/chat/tabs/Tab.tssrc/core/agent/customSpawn.tsdeactivateTab()/activateTab()src/features/chat/tabs/Tab.tsRelated Issues
Note
This is particularly disruptive for long-running agent tasks (codebase exploration, multi-file operations) that can take 5-10+ minutes. Users naturally want to use other tabs while waiting, but doing so kills the running task.