Skip to content

Commit 1661ee1

Browse files
authored
Merge pull request #943 from web3dev1337/fix/sidebar-worktree-status-followup
feat: comprehensive CLI status detection for all providers
2 parents ba83ff3 + 6c2e85a commit 1661ee1

5 files changed

Lines changed: 509 additions & 11 deletions

File tree

CODEBASE_DOCUMENTATION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ tests/unit/claudeVersionChecker.spawnOptions.test.js - Verifies Windows-hidden s
7272
tests/unit/worktreeHelper.spawnOptions.test.js - Verifies Windows-hidden spawn flags for auto-created worktree git commands
7373
tests/unit/commanderService.test.js - Covers Commander launch buffering, trust-prompt auto-accept, and preserved output history
7474
tests/unit/sessionManager.trustPrompt.test.js - Verifies auto-accept of Claude folder trust prompts in launched worktree sessions
75+
tests/unit/sessionManager.agentDetection.test.js - Covers manual Gemini command detection so provider-specific status heuristics receive the correct agent id
7576
server/utils/processUtils.js - Shared spawn/env hardening helpers
7677
├─ Windows packaging guardrails: applies `windowsHide`/`CREATE_NO_WINDOW`, augments GUI-app PATH with Git/node/npm/common CLI locations, and builds hidden PowerShell argument lists
7778
└─ Cross-platform behavior: non-Windows platforms pass through unchanged so Linux/macOS launch behavior stays stable

server/sessionManager.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1478,6 +1478,7 @@ class SessionManager extends EventEmitter {
14781478
}
14791479

14801480
knownAgents.set('opencode', 'opencode');
1481+
knownAgents.set('gemini', 'gemini');
14811482
knownAgents.set('aider', 'aider');
14821483

14831484
if (knownAgents.has(base)) {

server/statusDetector.js

Lines changed: 203 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ const logger = winston.createLogger({
1919
const ASSUME_BUSY_SINCE_OUTPUT_MS = 8000; // 8s
2020
const ASSUME_BUSY_SINCE_OUTPUT_AGENT_MS = 15000; // 15s
2121
const ASSUME_BUSY_SINCE_OUTPUT_CLAUDE_MS = 30000; // 30s
22+
const ASSUME_BUSY_SINCE_OUTPUT_CODEX_MS = 10000; // 10s
23+
const ASSUME_BUSY_SINCE_OUTPUT_GEMINI_MS = 6000; // 6s
24+
const ASSUME_BUSY_SINCE_OUTPUT_OPENCODE_MS = 5000; // 5s
25+
const ASSUME_BUSY_SINCE_OUTPUT_AIDER_MS = 10000; // 10s
2226

2327
class StatusDetector {
2428
constructor() {
@@ -28,7 +32,10 @@ class StatusDetector {
2832
/Cost: \$[\d.]+/, // Cost line - MOST RELIABLE done indicator
2933
/Total cost: \$[\d.]+/,
3034
/Session cost: \$[\d.]+/,
35+
/Total duration \(wall\):/, // Session summary line
36+
/Total code changes:/, // Session summary line
3137
/tokens used/i, // Token usage line
38+
/\d+ input, \d+ output.*cache/, // Per-model token usage line
3239
];
3340

3441
// Patterns indicating active tool usage (Claude is busy)
@@ -43,11 +50,26 @@ class StatusDetector {
4350
/Grep\(.*\)/, // Grep tool
4451
/Glob\(.*\)/, // Glob tool
4552
/Task\(.*\)/, // Task tool
53+
/Agent\(.*\)/, // Sub-agent tool
54+
/WebFetch\(.*\)/, // Web fetch tool
55+
/WebSearch\(.*\)/, // Web search tool
56+
/NotebookEdit\(.*\)/, // Notebook edit tool
57+
/NotebookRead\(.*\)/, // Notebook read tool
58+
/Skill\(.*\)/, // Skill execution tool
59+
/AskUserQuestion\(.*\)/, // User question tool
60+
/ToolSearch\(.*\)/, // Tool search tool
61+
/TodoWrite\(.*\)/, // Todo write tool
62+
/TaskOutput\(.*\)/, // Task output tool
63+
/TaskStop\(.*\)/, // Task stop tool
4664
];
4765

48-
// Patterns that suggest Claude is typing (busy) - more conservative
66+
// Patterns that suggest Claude is typing/processing (busy)
4967
this.typingPatterns = [
5068
/ Thinking/, // Thinking indicator
69+
/Waiting for permission/, // Permission prompt pending
70+
/Waiting for task/, // Sub-agent task pending
71+
/Running command/, // Bash tool executing
72+
/compacting conversation/i, // Context compaction in progress
5173
];
5274

5375
// Per-session state (StatusDetector is shared across sessions).
@@ -91,9 +113,163 @@ class StatusDetector {
91113
return out;
92114
}
93115

116+
normalizeAgent(agent) {
117+
const normalized = String(agent || '').trim().toLowerCase();
118+
if (!normalized) return null;
119+
if (normalized === 'gemini-cli') return 'gemini';
120+
if (normalized === 'open-code') return 'opencode';
121+
return normalized;
122+
}
123+
124+
getAssumeBusyWindowMs({ agent, isAgentTerminal, claudeLikely }) {
125+
const normalizedAgent = this.normalizeAgent(agent);
126+
if (normalizedAgent === 'codex') return ASSUME_BUSY_SINCE_OUTPUT_CODEX_MS;
127+
if (normalizedAgent === 'gemini') return ASSUME_BUSY_SINCE_OUTPUT_GEMINI_MS;
128+
if (normalizedAgent === 'opencode') return ASSUME_BUSY_SINCE_OUTPUT_OPENCODE_MS;
129+
if (normalizedAgent === 'aider') return ASSUME_BUSY_SINCE_OUTPUT_AIDER_MS;
130+
if (claudeLikely) return ASSUME_BUSY_SINCE_OUTPUT_CLAUDE_MS;
131+
if (isAgentTerminal) return ASSUME_BUSY_SINCE_OUTPUT_AGENT_MS;
132+
return ASSUME_BUSY_SINCE_OUTPUT_MS;
133+
}
134+
135+
detectProviderStatus(agent, context = {}) {
136+
const normalizedAgent = this.normalizeAgent(agent);
137+
if (!normalizedAgent || normalizedAgent === 'claude') return null;
138+
139+
const {
140+
recentOutput = '',
141+
recentAll = '',
142+
trimmedLastNonEmptyLine = '',
143+
hasRecentOutput = false,
144+
} = context;
145+
146+
if (normalizedAgent === 'codex') {
147+
// Codex waiting: user-facing prompts and approval dialogs
148+
if (
149+
trimmedLastNonEmptyLine === '>' ||
150+
/^codex>\s*$/i.test(trimmedLastNonEmptyLine) ||
151+
(/OpenAI Codex/i.test(recentOutput) && /\? for shortcuts/i.test(recentOutput)) ||
152+
/Choose how you'd like Codex to proceed\./i.test(recentOutput) ||
153+
/Choose an action/i.test(recentOutput) ||
154+
/Select provider\?/i.test(recentOutput) ||
155+
/Use .*press enter to confirm/i.test(recentOutput)
156+
) {
157+
return 'waiting';
158+
}
159+
// Codex busy: active processing indicators
160+
if (
161+
hasRecentOutput && (
162+
/esc to interrupt/i.test(recentOutput) ||
163+
/tab to add notes/i.test(recentOutput)
164+
)
165+
) {
166+
return 'busy';
167+
}
168+
return null;
169+
}
170+
171+
if (normalizedAgent === 'gemini') {
172+
const hasGeminiPrompt = /Type your message or @path\/to\/file/i.test(recentOutput);
173+
const hasGeminiChrome = (
174+
/\? for shortcuts/i.test(recentOutput)
175+
|| /Shift\+Tab to accept edits/i.test(recentOutput)
176+
|| /workspace \(/i.test(recentOutput)
177+
);
178+
179+
// Gemini waiting: input prompts, auth dialogs, trust dialogs, tool confirmations
180+
if (
181+
/Waiting for authentication\.\.\./i.test(recentOutput) ||
182+
/Waiting for verification\.\.\./i.test(recentOutput) ||
183+
/Do you trust the files in this folder\?/i.test(recentOutput) ||
184+
/Need approval\?/i.test(recentOutput) ||
185+
/Apply this change\?/i.test(recentOutput) ||
186+
/Allow execution of/i.test(recentOutput) ||
187+
/Do you want to proceed\?/i.test(recentOutput) ||
188+
/Ready to start implementation\?/i.test(recentOutput) ||
189+
/Modify Trust Level/i.test(recentOutput) ||
190+
/Use arrow keys to navigate, Enter to confirm, Esc to cancel\./i.test(recentOutput) ||
191+
(/Press Ctrl\+[CD] again to exit\./i.test(recentOutput) && hasGeminiPrompt) ||
192+
(hasGeminiPrompt && hasGeminiChrome)
193+
) {
194+
return 'waiting';
195+
}
196+
197+
// Gemini busy: thinking, processing, initializing
198+
if (
199+
hasRecentOutput && (
200+
/Thinking\.\.\./i.test(recentOutput) ||
201+
/\(esc to cancel,\s*[\d:smh]+\)/i.test(recentOutput) ||
202+
/\(press tab to focus\)/i.test(recentOutput) ||
203+
/Waiting for MCP servers to initialize/i.test(recentAll)
204+
)
205+
) {
206+
return 'busy';
207+
}
208+
209+
return null;
210+
}
211+
212+
if (normalizedAgent === 'opencode') {
213+
const hasOpenCodePrompt = /Ask anything\.\.\./i.test(recentOutput);
214+
const hasOpenCodeChrome = (
215+
/ctrl\+t\s+variants/i.test(recentOutput)
216+
|| /ctrl\+p\s+commands/i.test(recentOutput)
217+
|| /\btab\s+agents\b/i.test(recentOutput)
218+
);
219+
220+
// OpenCode waiting: input prompt with chrome, or idle help text
221+
if (
222+
(hasOpenCodePrompt && hasOpenCodeChrome) ||
223+
/press enter to send the message/i.test(recentOutput)
224+
) {
225+
return 'waiting';
226+
}
227+
228+
// OpenCode busy: thinking, generating, tool calls, working
229+
if (
230+
hasRecentOutput && (
231+
/Thinking\.\.\./i.test(recentOutput) ||
232+
/Generating\.\.\./i.test(recentOutput) ||
233+
/Working\.\.\./i.test(recentOutput) ||
234+
/Waiting for tool response\.\.\./i.test(recentOutput) ||
235+
/Building tool call\.\.\./i.test(recentOutput) ||
236+
/press esc to exit cancel/i.test(recentOutput)
237+
)
238+
) {
239+
return 'busy';
240+
}
241+
242+
return null;
243+
}
244+
245+
if (normalizedAgent === 'aider') {
246+
// Aider waiting: standard prompt or multiline prompt
247+
if (
248+
trimmedLastNonEmptyLine === '>' ||
249+
/^multi>\s*$/i.test(trimmedLastNonEmptyLine) ||
250+
/^aider>\s*$/i.test(trimmedLastNonEmptyLine)
251+
) {
252+
return 'waiting';
253+
}
254+
// Aider busy: waiting for LLM, thinking
255+
if (
256+
hasRecentOutput && (
257+
/Waiting for .*LLM/i.test(recentOutput) ||
258+
/Waiting for .*model/i.test(recentOutput) ||
259+
/think tokens/i.test(recentOutput)
260+
)
261+
) {
262+
return 'busy';
263+
}
264+
return null;
265+
}
266+
267+
return null;
268+
}
269+
94270
detectStatus(sessionId, buffer, options = {}) {
95271
const state = this.getState(sessionId);
96-
const agent = String(options?.agent || '').trim() || null;
272+
const agent = this.normalizeAgent(options?.agent);
97273
const isNonClaudeAgent = !!(agent && agent !== 'claude');
98274
if (agent) {
99275
state.agent = agent;
@@ -109,9 +285,11 @@ class StatusDetector {
109285
}
110286
const timeSinceOutput = now - state.lastOutputTime;
111287
const isAgentTerminal = /-(claude|codex)$/.test(String(sessionId || ''));
112-
const assumeBusyWindowMs = (state.claudeLikely || isAgentTerminal)
113-
? (state.claudeLikely ? ASSUME_BUSY_SINCE_OUTPUT_CLAUDE_MS : ASSUME_BUSY_SINCE_OUTPUT_AGENT_MS)
114-
: ASSUME_BUSY_SINCE_OUTPUT_MS;
288+
const assumeBusyWindowMs = this.getAssumeBusyWindowMs({
289+
agent,
290+
isAgentTerminal,
291+
claudeLikely: state.claudeLikely
292+
});
115293
const hasRecentOutput = timeSinceOutput < assumeBusyWindowMs;
116294

117295
// Get recent output for analysis
@@ -121,23 +299,37 @@ class StatusDetector {
121299
const lastNonEmptyLine = this.getLastNonEmptyLine(lines);
122300
const trimmedLastNonEmptyLine = lastNonEmptyLine.trim();
123301
const lastNonEmptyLines = this.getLastNonEmptyLines(lines, 6);
302+
const recentAll = lastNonEmptyLines.join('\n');
124303

125304
// If a different agent (e.g. Codex) is running in this terminal, avoid reusing Claude UI heuristics.
126305
if (isNonClaudeAgent) {
127306
state.claudeLikely = false;
128307
}
129308

130-
// Best-effort Codex interactive prompt detection.
131-
// This prevents the ">" prompt from being misclassified as idle (grey) and reduces dot flicker.
132-
if (agent === 'codex') {
133-
if (trimmedLastNonEmptyLine === '>' || /^codex>\s*$/.test(trimmedLastNonEmptyLine)) {
134-
return 'waiting';
309+
const providerStatus = this.detectProviderStatus(agent, {
310+
recentOutput,
311+
recentAll,
312+
trimmedLastNonEmptyLine,
313+
hasRecentOutput,
314+
});
315+
if (providerStatus) {
316+
return providerStatus;
317+
}
318+
319+
if (isNonClaudeAgent) {
320+
if (this.hasExplicitShellIndicator(recentAll, trimmedLastNonEmptyLine)) {
321+
return 'idle';
135322
}
323+
324+
if (timeSinceOutput < assumeBusyWindowMs && buffer.length > 100) {
325+
return 'busy';
326+
}
327+
328+
return 'idle';
136329
}
137330

138331
// Heuristic: determine whether Claude Code UI is likely active in this session.
139332
// This is used to avoid misclassifying shell-like prompts that can appear inside output while Claude is working.
140-
const recentAll = lastNonEmptyLines.join('\n');
141333
if (!isNonClaudeAgent) {
142334
if (/Welcome to Claude Code!/.test(recentAll) || /\? for shortcuts/.test(recentAll)) {
143335
state.claudeLikely = true;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
jest.mock('node-pty', () => ({
2+
spawn: jest.fn()
3+
}), { virtual: true });
4+
5+
jest.mock('winston', () => ({
6+
createLogger: () => ({
7+
debug: jest.fn(),
8+
info: jest.fn(),
9+
warn: jest.fn(),
10+
error: jest.fn()
11+
}),
12+
format: {
13+
combine: jest.fn(() => ({})),
14+
timestamp: jest.fn(() => ({})),
15+
errors: jest.fn(() => ({})),
16+
json: jest.fn(() => ({})),
17+
simple: jest.fn(() => ({})),
18+
colorize: jest.fn(() => ({}))
19+
},
20+
transports: {
21+
File: jest.fn(),
22+
Console: jest.fn()
23+
}
24+
}), { virtual: true });
25+
26+
jest.mock('../../server/sessionRecoveryService', () => ({
27+
clearSession: jest.fn(),
28+
updateSession: jest.fn(),
29+
updateAgent: jest.fn(),
30+
updateCwd: jest.fn(),
31+
updateConversation: jest.fn(),
32+
updateServer: jest.fn(),
33+
getSession: jest.fn(),
34+
getAllSessions: jest.fn(),
35+
init: jest.fn(),
36+
loadWorkspaceState: jest.fn(),
37+
getRecoveryInfo: jest.fn(),
38+
clearWorkspace: jest.fn(),
39+
markAgentInactive: jest.fn(),
40+
clearAgent: jest.fn()
41+
}));
42+
43+
const { SessionManager } = require('../../server/sessionManager');
44+
45+
describe('SessionManager agent detection', () => {
46+
it('detects gemini commands directly', () => {
47+
const sessionManager = new SessionManager({ emit: jest.fn() }, null);
48+
expect(sessionManager.detectAgentFromCommand('gemini', [], 'gemini')).toBe('gemini');
49+
});
50+
51+
it('detects gemini commands launched through npm exec', () => {
52+
const sessionManager = new SessionManager({ emit: jest.fn() }, null);
53+
expect(
54+
sessionManager.detectAgentFromCommand('npm', ['exec', 'gemini'], 'npm exec gemini')
55+
).toBe('gemini');
56+
});
57+
});

0 commit comments

Comments
 (0)