diff --git a/CHANGELOG.md b/CHANGELOG.md index c6bb692..bb393ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,17 +14,12 @@ All notable changes to Claude Code Studio will be documented in this file. - Composer UX improvements — expand toggle, drag handle, line/char count (84e2296) - Notes pad replaces Inbox tab in right pane (7354643) - Collapsible left sidebar with Ctrl+B shortcut (21f7363) -- Plugin system with MCP-based architecture (e14a91c) - Drag-and-drop between panes via toolbar handle (757984a) ### Fixed - Preserve claudeSessionId across app restarts for session recovery (f4a3a57) - Japanese input and multiline text with bracketed paste (21ed2f1) -- SSH+tmux session reconnection with claude lifecycle management (8575494) - Remove bottom gap in ActivityMap/ConfigMap, responsive stats cards (a886cf2) -- Security hardening, lint fixes, remove bundled Aurelius plugin (3302f5a) -- TitleBar platform-aware padding, Linux repaint cleanup (751917f) -- Linux GPU stability — SIGSEGV fix, titlebar overlay crash fix (5e01a4c) ### Documentation - Screenshots added to README (be103b8) diff --git a/src/main/__tests__/ptySessionRecovery.test.ts b/src/main/__tests__/ptySessionRecovery.test.ts new file mode 100644 index 0000000..640c316 --- /dev/null +++ b/src/main/__tests__/ptySessionRecovery.test.ts @@ -0,0 +1,433 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' +import type { Agent, AgentStatus } from '@shared/types' + +// --------------------------------------------------------------------------- +// Mock: electron (transitive dep via sessionManager → i18n) +// --------------------------------------------------------------------------- +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp'), getLocale: vi.fn(() => 'en') }, + dialog: { showMessageBox: vi.fn() }, + BrowserWindow: vi.fn(), + ipcMain: { handle: vi.fn(), on: vi.fn() }, + session: { defaultSession: { webRequest: { onHeadersReceived: vi.fn() } } } +})) + +vi.mock('@electron-toolkit/utils', () => ({ + is: { dev: true }, + electronApp: { setAppUserModelId: vi.fn() }, + optimizer: { watchWindowShortcuts: vi.fn() } +})) + +// --------------------------------------------------------------------------- +// Mock: node-pty +// --------------------------------------------------------------------------- +const mockPtyWrite = vi.fn() +const mockPtyKill = vi.fn() +const mockPtyResize = vi.fn() + +let ptyOnDataHandler: ((data: string) => void) | null = null +let ptyOnExitHandler: ((e: { exitCode: number }) => void) | null = null + +const mockPtyProcess = { + pid: 12345, + onData: vi.fn((cb: (data: string) => void) => { ptyOnDataHandler = cb }), + onExit: vi.fn((cb: (e: { exitCode: number }) => void) => { ptyOnExitHandler = cb }), + write: mockPtyWrite, + kill: mockPtyKill, + resize: mockPtyResize, +} + +vi.mock('node-pty', () => ({ + spawn: vi.fn(() => mockPtyProcess) +})) + +// --------------------------------------------------------------------------- +// Mock: child_process (used by resolveClaudePath) +// --------------------------------------------------------------------------- +vi.mock('child_process', () => ({ + execFile: vi.fn(), + execFileSync: vi.fn(() => 'claude'), + spawn: vi.fn() +})) + +// --------------------------------------------------------------------------- +// Mock: fs (used by resolveClaudePath + validateProjectPath) +// --------------------------------------------------------------------------- +vi.mock('fs', () => ({ + existsSync: vi.fn((p: string) => { + // Return true for project paths (validateProjectPath), false for claude binary lookup + if (typeof p === 'string' && p.includes('test-project')) return true + return false + }), + statSync: vi.fn(() => ({ isDirectory: () => true })), + readFileSync: vi.fn(() => '{}') +})) + +// --------------------------------------------------------------------------- +// Mock: uuid +// --------------------------------------------------------------------------- +let uuidCounter = 0 +vi.mock('uuid', () => ({ + v4: vi.fn(() => `mock-uuid-${++uuidCounter}`) +})) + +// --------------------------------------------------------------------------- +// Mock: database +// --------------------------------------------------------------------------- +const mockDatabase = { + getScrollback: vi.fn(() => ''), + saveAllScrollbacks: vi.fn(), + updateAgent: vi.fn((_id: string, _updates: Record) => makeAgent()), + getAgent: vi.fn((_id: string) => makeAgent()), +} + +// --------------------------------------------------------------------------- +// Helper: create a test Agent +// --------------------------------------------------------------------------- +function makeAgent(overrides: Partial = {}): Agent { + return { + id: 'agent-1', + name: 'Test Agent', + icon: null, + roleLabel: null, + workspaceId: null, + projectPath: process.platform === 'win32' ? 'C:/Users/user/test-project' : '/home/user/test-project', + projectName: 'test-project', + sessionNumber: 1, + status: 'idle' as AgentStatus, + currentTask: null, + systemPrompt: null, + claudeSessionId: null, + isPinned: false, + skills: [], + teamId: null, + reportTo: null, + parentAgentId: null, + isTemporary: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides + } +} + +// --------------------------------------------------------------------------- +// Import AFTER mocks are registered +// --------------------------------------------------------------------------- +import * as pty from 'node-pty' +import { PtySessionManager } from '../ptySessionManager' + +describe('PtySessionManager — session recovery logic', () => { + let manager: PtySessionManager + const onData = vi.fn() + const onStatusChange = vi.fn() + const onExit = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + uuidCounter = 0 + ptyOnDataHandler = null + ptyOnExitHandler = null + + manager = new PtySessionManager( + mockDatabase as unknown as import('../database').Database, + onData, + onStatusChange, + onExit + ) + }) + + // ========================================================================= + // 1. --resume vs --session-id argument construction + // ========================================================================= + describe('argument construction (--resume vs --session-id)', () => { + it('uses --resume when claudeSessionId exists (reconnection)', async () => { + const agent = makeAgent({ claudeSessionId: 'existing-session-123' }) + await manager.startSession(agent) + + const spawnCall = (pty.spawn as Mock).mock.calls[0] + const args: string[] = spawnCall[1] + + // On Windows: ['/c', claudePath, '--resume', sessionId, '--verbose'] + // On Unix: ['--resume', sessionId, '--verbose'] + expect(args).toEqual(expect.arrayContaining(['--resume', 'existing-session-123', '--verbose'])) + expect(args).not.toEqual(expect.arrayContaining(['--session-id'])) + }) + + it('uses --session-id when claudeSessionId is null (new session)', async () => { + const agent = makeAgent({ claudeSessionId: null }) + await manager.startSession(agent) + + const spawnCall = (pty.spawn as Mock).mock.calls[0] + const args: string[] = spawnCall[1] + + // Should generate a new UUID and use --session-id + expect(args).toEqual(expect.arrayContaining(['--session-id'])) + expect(args).toEqual(expect.arrayContaining(['--verbose'])) + expect(args).not.toEqual(expect.arrayContaining(['--resume'])) + }) + + it('saves the new session ID to database for new sessions', async () => { + const agent = makeAgent({ claudeSessionId: null }) + await manager.startSession(agent) + + // updateAgent should be called with the generated session ID + expect(mockDatabase.updateAgent).toHaveBeenCalledWith( + 'agent-1', + expect.objectContaining({ + status: 'active', + claudeSessionId: expect.stringMatching(/^mock-uuid-/) + }) + ) + }) + + it('preserves existing session ID for resumed sessions', async () => { + const agent = makeAgent({ claudeSessionId: 'keep-this-id' }) + await manager.startSession(agent) + + expect(mockDatabase.updateAgent).toHaveBeenCalledWith( + 'agent-1', + expect.objectContaining({ + status: 'active', + claudeSessionId: 'keep-this-id' + }) + ) + }) + + it('includes --system-prompt when agent has systemPrompt', async () => { + const agent = makeAgent({ claudeSessionId: null, systemPrompt: 'You are a helper.' }) + await manager.startSession(agent) + + const spawnCall = (pty.spawn as Mock).mock.calls[0] + const args: string[] = spawnCall[1] + + expect(args).toEqual(expect.arrayContaining(['--system-prompt', 'You are a helper.'])) + }) + }) + + // ========================================================================= + // 2. Auto-recovery counter logic (MAX_AUTO_RECOVERY = 3) + // ========================================================================= + describe('auto-recovery counter logic', () => { + it('triggers auto-recovery on unexpected exit (exitCode != 0)', async () => { + const agent = makeAgent({ claudeSessionId: 'sess-1' }) + mockDatabase.getAgent.mockReturnValue(agent) + await manager.startSession(agent) + + // Simulate unexpected exit (e.g., network error) + ptyOnExitHandler!({ exitCode: 1 }) + + // Should set status to error and schedule recovery + expect(mockDatabase.updateAgent).toHaveBeenCalledWith( + 'agent-1', + expect.objectContaining({ status: 'error' }) + ) + expect(onStatusChange).toHaveBeenCalledWith('agent-1', 'error') + expect(onExit).toHaveBeenCalledWith('agent-1', 1) + }) + + it('performs recovery after base delay (2000ms for first attempt)', async () => { + const agent = makeAgent({ claudeSessionId: 'sess-1' }) + mockDatabase.getAgent.mockReturnValue(agent) + await manager.startSession(agent) + + // Clear the spawn mock to track recovery call + ;(pty.spawn as Mock).mockClear() + + // Trigger unexpected exit + ptyOnExitHandler!({ exitCode: 1 }) + + // Before delay: no new spawn + expect(pty.spawn).not.toHaveBeenCalled() + + // Advance past the 2000ms base delay + await vi.advanceTimersByTimeAsync(2000) + + // Recovery should have spawned a new pty + expect(pty.spawn).toHaveBeenCalledTimes(1) + }) + + it('uses exponential backoff: 2s, 4s, 8s for attempts 1-3', async () => { + const agent = makeAgent({ claudeSessionId: 'sess-1' }) + mockDatabase.getAgent.mockReturnValue(agent) + + // Attempt 1: trigger first session + exit + await manager.startSession(agent) + ;(pty.spawn as Mock).mockClear() + ptyOnExitHandler!({ exitCode: 1 }) + + // First recovery at 2000ms + await vi.advanceTimersByTimeAsync(2000) + expect(pty.spawn).toHaveBeenCalledTimes(1) + + // The new session should have _autoRecoveryCount = 1 + // Simulate exit again from the recovered session + ;(pty.spawn as Mock).mockClear() + ptyOnExitHandler!({ exitCode: 1 }) + + // Second recovery at 4000ms (2000 * 2^1) + await vi.advanceTimersByTimeAsync(3999) + expect(pty.spawn).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(pty.spawn).toHaveBeenCalledTimes(1) + }) + + it('stops recovery after MAX_AUTO_RECOVERY (3) attempts', async () => { + const agent = makeAgent({ claudeSessionId: 'sess-1' }) + mockDatabase.getAgent.mockReturnValue(agent) + + await manager.startSession(agent) + + // First: trigger exit → recovery attempt 1 + ptyOnExitHandler!({ exitCode: 1 }) + await vi.advanceTimersByTimeAsync(2000) + + // Second: trigger exit → recovery attempt 2 + ptyOnExitHandler!({ exitCode: 1 }) + await vi.advanceTimersByTimeAsync(4000) + + // Third: trigger exit → recovery attempt 3 + ptyOnExitHandler!({ exitCode: 1 }) + await vi.advanceTimersByTimeAsync(8000) + + // Now _autoRecoveryCount should be 3. Next exit should NOT trigger recovery. + ;(pty.spawn as Mock).mockClear() + mockDatabase.updateAgent.mockClear() + + ptyOnExitHandler!({ exitCode: 1 }) + + // Advance well past any possible delay — no new spawn should happen + await vi.advanceTimersByTimeAsync(20000) + + // The final exit should set status to 'error' without scheduling recovery + expect(mockDatabase.updateAgent).toHaveBeenCalledWith( + 'agent-1', + expect.objectContaining({ status: 'error' }) + ) + // No new pty spawn after exhausting retries + expect(pty.spawn).not.toHaveBeenCalled() + }) + }) + + // ========================================================================= + // 3. User-initiated stop (isKilled) does NOT trigger recovery + // ========================================================================= + describe('user-initiated stop (isKilled)', () => { + it('does not trigger auto-recovery when session is stopped by user', async () => { + const agent = makeAgent({ claudeSessionId: 'sess-1' }) + await manager.startSession(agent) + + ;(pty.spawn as Mock).mockClear() + + // stopSession deletes from sessions map BEFORE pty.kill() triggers onExit + manager.stopSession('agent-1') + + // onExit fires with non-zero code, but isKilled should be true + // because the session was deleted from the map + ptyOnExitHandler!({ exitCode: 1 }) + + // Advance timers to ensure no recovery is scheduled + await vi.advanceTimersByTimeAsync(10000) + + // No new pty spawn should happen + expect(pty.spawn).not.toHaveBeenCalled() + }) + + it('does not trigger auto-recovery on clean exit (exitCode 0)', async () => { + const agent = makeAgent({ claudeSessionId: 'sess-1' }) + await manager.startSession(agent) + + ;(pty.spawn as Mock).mockClear() + + // Normal exit + ptyOnExitHandler!({ exitCode: 0 }) + + await vi.advanceTimersByTimeAsync(10000) + + // Status should be 'idle', not 'error' + expect(mockDatabase.updateAgent).toHaveBeenCalledWith( + 'agent-1', + expect.objectContaining({ status: 'idle' }) + ) + expect(pty.spawn).not.toHaveBeenCalled() + }) + + it('sets status to idle on clean exit, error on crash', async () => { + // Clean exit + const agent1 = makeAgent({ id: 'agent-clean', claudeSessionId: 'sess-clean' }) + await manager.startSession(agent1) + ptyOnExitHandler!({ exitCode: 0 }) + + expect(mockDatabase.updateAgent).toHaveBeenCalledWith( + 'agent-clean', + expect.objectContaining({ status: 'idle' }) + ) + + // Reset for crash test + mockDatabase.updateAgent.mockClear() + const agent2 = makeAgent({ id: 'agent-crash', claudeSessionId: 'sess-crash' }) + mockDatabase.getAgent.mockReturnValue(agent2) + await manager.startSession(agent2) + ptyOnExitHandler!({ exitCode: 1 }) + + expect(mockDatabase.updateAgent).toHaveBeenCalledWith( + 'agent-crash', + expect.objectContaining({ status: 'error' }) + ) + }) + }) + + // ========================================================================= + // 4. Recovery counter resets on stable session + // ========================================================================= + describe('recovery counter reset', () => { + it('resets auto-recovery counter when session becomes active/thinking/tool_running', async () => { + const agent = makeAgent({ claudeSessionId: 'sess-1' }) + mockDatabase.getAgent.mockReturnValue(agent) + await manager.startSession(agent) + + // Simulate an exit + recovery + ptyOnExitHandler!({ exitCode: 1 }) + await vi.advanceTimersByTimeAsync(2000) + + // Now recovered session is running. Simulate it becoming active via status detection. + // Feed data that triggers tool_running status + if (ptyOnDataHandler) { + ptyOnDataHandler('Read(/some/file)') + } + + // Now if it exits again, it should start recovery from count 0 + ;(pty.spawn as Mock).mockClear() + ptyOnExitHandler!({ exitCode: 1 }) + + // Should recover again (delay = 2000ms for attempt 1, not 4000ms) + await vi.advanceTimersByTimeAsync(2000) + expect(pty.spawn).toHaveBeenCalledTimes(1) + }) + }) + + // ========================================================================= + // 5. Scrollback is saved before recovery + // ========================================================================= + describe('scrollback persistence during recovery', () => { + it('saves scrollback to database on exit before recovery', async () => { + const agent = makeAgent({ claudeSessionId: 'sess-1' }) + mockDatabase.getAgent.mockReturnValue(agent) + await manager.startSession(agent) + + // Feed some data to populate scrollback + if (ptyOnDataHandler) { + ptyOnDataHandler('Some output data\n') + } + + ptyOnExitHandler!({ exitCode: 1 }) + + // saveAllScrollbacks should have been called with the agent's buffer + expect(mockDatabase.saveAllScrollbacks).toHaveBeenCalledWith( + expect.objectContaining({ + 'agent-1': expect.stringContaining('Some output data') + }) + ) + }) + }) +}) diff --git a/src/main/plugins/__tests__/pluginEnvFilter.test.ts b/src/main/plugins/__tests__/pluginEnvFilter.test.ts index f65e86a..2ee6753 100644 --- a/src/main/plugins/__tests__/pluginEnvFilter.test.ts +++ b/src/main/plugins/__tests__/pluginEnvFilter.test.ts @@ -142,4 +142,18 @@ describe('filterEnvForPlugin', () => { const result = filterEnvForPlugin({}) expect(Object.keys(result)).toHaveLength(0) }) + + it('blocks API_KEY variants without $ anchor (e.g. SOME_API_KEY_FILE)', () => { + const env: NodeJS.ProcessEnv = { + SOME_API_KEY_FILE: '/path/to/key', + MY_API_KEY_PATH: '/another/path', + API_KEY_ROTATION_DATE: '2026-01-01', + PATH: '/usr/bin' + } + const result = filterEnvForPlugin(env) + expect(result.SOME_API_KEY_FILE).toBeUndefined() + expect(result.MY_API_KEY_PATH).toBeUndefined() + expect(result.API_KEY_ROTATION_DATE).toBeUndefined() + expect(result.PATH).toBe('/usr/bin') + }) }) diff --git a/src/main/plugins/pluginEnvFilter.ts b/src/main/plugins/pluginEnvFilter.ts index 08e3ccb..5e64506 100644 --- a/src/main/plugins/pluginEnvFilter.ts +++ b/src/main/plugins/pluginEnvFilter.ts @@ -66,7 +66,7 @@ const SENSITIVE_PATTERNS = [ /password/i, /private.?key/i, /auth.?token/i, - /api.?key$/i, + /api.?key/i, /credentials/i, /access.?key/i ] diff --git a/src/main/plugins/pluginManager.ts b/src/main/plugins/pluginManager.ts index 16cb665..12695b3 100644 --- a/src/main/plugins/pluginManager.ts +++ b/src/main/plugins/pluginManager.ts @@ -42,6 +42,11 @@ export class PluginManager { paths.push(join(home, 'AppData', 'Local', 'Programs')) paths.push(join(home, 'AppData', 'Local', 'claude-code-studio', 'plugins')) } + if (process.platform === 'darwin') { + paths.push('/usr/local/bin') + paths.push('/opt/homebrew/bin') + paths.push(join(home, 'Library', 'Application Support', 'claude-code-studio', 'plugins')) + } // Normalize separators for consistent startsWith comparison return paths.map((p) => p.replace(/\\/g, '/')) } @@ -137,6 +142,14 @@ export class PluginManager { const localBin = join(homedir(), '.local', 'bin', cmd) if (existsSync(localBin)) return true + // Check macOS-specific paths (Homebrew) + if (process.platform === 'darwin') { + const homebrewBin = join('/opt/homebrew/bin', cmd) + if (existsSync(homebrewBin)) return true + const usrLocalBin = join('/usr/local/bin', cmd) + if (existsSync(usrLocalBin)) return true + } + // Check PATH (cross-platform) try { const whichCmd = process.platform === 'win32' ? 'where' : 'which' @@ -162,6 +175,14 @@ export class PluginManager { const localBin = join(homedir(), '.local', 'bin', cmd) if (existsSync(localBin)) return localBin + // Check macOS-specific paths (Homebrew) + if (process.platform === 'darwin') { + const homebrewBin = join('/opt/homebrew/bin', cmd) + if (existsSync(homebrewBin)) return homebrewBin + const usrLocalBin = join('/usr/local/bin', cmd) + if (existsSync(usrLocalBin)) return usrLocalBin + } + // Fall back to PATH return cmd } @@ -177,7 +198,8 @@ export class PluginManager { const args = entry.manifest.mcp.args const filteredEnv = filterEnvForPlugin(process.env) - const pluginEnv = extraEnvVars ? { ...filteredEnv, ...extraEnvVars } : filteredEnv + const filteredExtra = extraEnvVars ? filterEnvForPlugin(extraEnvVars as NodeJS.ProcessEnv) : undefined + const pluginEnv = filteredExtra ? { ...filteredEnv, ...filteredExtra } : filteredEnv const proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], diff --git a/src/main/ptySessionManager.ts b/src/main/ptySessionManager.ts index f5cebf2..fd54b9c 100644 --- a/src/main/ptySessionManager.ts +++ b/src/main/ptySessionManager.ts @@ -20,6 +20,10 @@ interface PtySession { _retryCount?: number _conflictRetried?: boolean _idleTimer?: ReturnType + _autoRecoveryCount: number + _isResumed: boolean + _cols: number + _rows: number } export interface AgentMemoryInfo { @@ -76,6 +80,12 @@ export class PtySessionManager { return 'claude' } + /** Maximum number of automatic recovery attempts after unexpected exit */ + private static readonly MAX_AUTO_RECOVERY = 3 + + /** Base delay (ms) for exponential backoff on auto-recovery */ + private static readonly RECOVERY_BASE_DELAY_MS = 2000 + async startSession(agent: Agent, cols = 120, rows = 30): Promise { validateProjectPath(agent.projectPath) @@ -88,9 +98,12 @@ export class PtySessionManager { } const sessionId = agent.claudeSessionId || uuidv4() + const isResume = !!agent.claudeSessionId - // Interactive mode — no stream-json flags - const args: string[] = ['--session-id', sessionId, '--verbose'] + // Interactive mode — use --resume for existing sessions to restore conversation + const args: string[] = isResume + ? ['--resume', sessionId, '--verbose'] + : ['--session-id', sessionId, '--verbose'] if (agent.systemPrompt) { args.push('--system-prompt', agent.systemPrompt) @@ -118,7 +131,11 @@ export class PtySessionManager { lastStatus: 'active', lastOutputLine: '', memoryMB: 0, - _conflictRetried: false + _conflictRetried: false, + _autoRecoveryCount: existing?._autoRecoveryCount ?? 0, + _isResumed: isResume, + _cols: cols, + _rows: rows } ptyProcess.onData((data: string) => { @@ -158,6 +175,40 @@ export class PtySessionManager { const isKilled = !this.sessions.has(agent.id) const isWindowsCtrlC = process.platform === 'win32' && exitCode === -1073741510 const isSuccess = exitCode === 0 || isKilled || isWindowsCtrlC + + // Auto-recovery: retry on unexpected exit (network errors, crashes) + // Skip if user explicitly stopped or if max retries reached + if (!isSuccess && !isKilled && session._autoRecoveryCount < PtySessionManager.MAX_AUTO_RECOVERY) { + const attempt = session._autoRecoveryCount + 1 + const delay = PtySessionManager.RECOVERY_BASE_DELAY_MS * Math.pow(2, attempt - 1) + console.warn(`[PtySession] Unexpected exit (code=${exitCode}) for ${agent.id}. Auto-recovery attempt ${attempt}/${PtySessionManager.MAX_AUTO_RECOVERY} in ${delay}ms`) + + this.database.updateAgent(agent.id, { status: 'error' }) + this.onStatusChange(agent.id, 'error') + this.sessions.delete(agent.id) + + setTimeout(() => { + const currentAgent = this.database.getAgent(agent.id) + if (!currentAgent || this.sessions.has(agent.id)) return + // Carry forward the recovery count + const recoveryAgent = { ...currentAgent } + this.startSession(recoveryAgent, session._cols, session._rows) + .then(() => { + const newSession = this.sessions.get(agent.id) + if (newSession) newSession._autoRecoveryCount = attempt + console.info(`[PtySession] Auto-recovery succeeded for ${agent.id} (attempt ${attempt})`) + }) + .catch((err) => { + console.error(`[PtySession] Auto-recovery failed for ${agent.id}:`, err) + this.database.updateAgent(agent.id, { status: 'error' }) + this.onStatusChange(agent.id, 'error') + }) + }, delay) + + this.onExit(agent.id, exitCode) + return + } + const status: AgentStatus = isSuccess ? 'idle' : 'error' this.database.updateAgent(agent.id, { status }) this.onStatusChange(agent.id, status) @@ -352,6 +403,10 @@ export class PtySessionManager { private setStatus(session: PtySession, newStatus: AgentStatus): void { if (newStatus === session.lastStatus) return session.lastStatus = newStatus + // Reset auto-recovery counter once session is stable again + if (newStatus === 'active' || newStatus === 'thinking' || newStatus === 'tool_running') { + session._autoRecoveryCount = 0 + } this.database.updateAgent(session.agentId, { status: newStatus, ...(newStatus === 'active' ? { currentTask: null } : {}) diff --git a/src/main/utils.ts b/src/main/utils.ts index fbe3b0c..850239b 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -5,6 +5,9 @@ export function stripAnsiCodes(str: string): string { .replace(/\x1B\][^\x07]*\x07/g, '') // OSC sequences .replace(/\x1B[()][0-9A-B]/g, '') // Character set selection .replace(/\x1B[\x20-\x2F]*[\x30-\x7E]/g, '') // Other ESC sequences - .replace(/O\?[\d;]*c/g, '') // Orphaned DA responses (ESC lost at chunk boundary) + // Orphaned Device Attributes (DA) response: terminals reply with ESC[?1;2c etc. + // When output is chunked, the leading ESC may land in a previous chunk, leaving + // only the tail "O?c" in the current one. Strip it to avoid UI noise. + .replace(/O\?[\d;]*c/g, '') } /* eslint-enable no-control-regex */ diff --git a/src/shared/types.ts b/src/shared/types.ts index 116bfd0..4ca4ff0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -593,6 +593,7 @@ export interface PluginManifest { toolbarButtons: { id: string; tool: string; icon: string; prompt: string }[] contextTab?: { id: string; label: string; icon: string; component: string } } + permissions?: PluginPermissions install?: { check: string steps: string[]