Skip to content
Open
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
10 changes: 10 additions & 0 deletions claude-action-log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Claude Action Log — clui-cc-public

This file keeps a record of everything Claude does in this project. Each entry explains what happened and why, in plain English.

---

## 2026-03-18

**Session:** Initial log file created.
- This log was set up so there's a running history of what Claude does in this project. Think of it like a diary — every time Claude makes a change, runs a command, or asks for permission, it gets written down here so you can look back and understand what happened.
23 changes: 18 additions & 5 deletions src/main/claude/pty-run-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import { join } from 'path'
import { execSync } from 'child_process'
import { appendFileSync, chmodSync, existsSync, statSync } from 'fs'
import type { NormalizedEvent, RunOptions, EnrichedError } from '../../shared/types'
import { getCliEnv } from '../cli-env'

// node-pty is a native module — require at runtime to avoid Vite bundling issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
Expand Down Expand Up @@ -276,6 +275,7 @@ export class PtyRunManager extends EventEmitter {
private activeRuns = new Map<string, PtyRunHandle>()
private _finishedRuns = new Map<string, PtyRunHandle>()
private claudeBinary: string
private _loginShellPath = ''

constructor() {
super()
Expand Down Expand Up @@ -325,18 +325,31 @@ export class PtyRunManager extends EventEmitter {
}

try {
return execSync('/bin/zsh -ilc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv() }).trim()
return execSync('/bin/zsh -lc "whence -p claude"', { encoding: 'utf-8' }).trim()
} catch {}

try {
return execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv() }).trim()
return execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8' }).trim()
} catch {}

return 'claude'
}

private _getEnv(): NodeJS.ProcessEnv {
const env = getCliEnv()
const env = { ...process.env }
delete env.CLAUDECODE

if (!this._loginShellPath) {
try {
this._loginShellPath = execSync('/bin/zsh -lc "echo $PATH"', { encoding: 'utf-8' }).trim()
} catch {
this._loginShellPath = ''
}
}
if (this._loginShellPath) {
env.PATH = this._loginShellPath
}

const binDir = this.claudeBinary.substring(0, this.claudeBinary.lastIndexOf('/'))
if (env.PATH && !env.PATH.includes(binDir)) {
env.PATH = `${binDir}:${env.PATH}`
Expand Down Expand Up @@ -563,7 +576,7 @@ export class PtyRunManager extends EventEmitter {
// ─── Permission phase: collecting detection context ───
if (handle.permissionPhase === 'detecting' || handle.permissionPhase === 'idle') {
this._checkPermissionInBuffer(requestId, handle, cleaned)
if (handle.permissionPhase === 'waiting_user') {
if ((handle.permissionPhase as string) === 'waiting_user') {
return // Permission prompt detected and emitted
}
}
Expand Down
180 changes: 94 additions & 86 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, dialog, screen, globalShortcut, Tray, Menu, nativeImage, nativeTheme, shell, systemPreferences } from 'electron'
import { app, BrowserWindow, ipcMain, dialog, screen, globalShortcut, Tray, Menu, nativeImage, nativeTheme, shell } from 'electron'
import { join } from 'path'
import { existsSync, readdirSync, statSync, createReadStream } from 'fs'
import { createInterface } from 'readline'
Expand All @@ -7,9 +7,9 @@ import { ControlPlane } from './claude/control-plane'
import { ensureSkills, type SkillStatus } from './skills/installer'
import { fetchCatalog, listInstalled, installPlugin, uninstallPlugin } from './marketplace/catalog'
import { log as _log, LOG_FILE, flushLogs } from './logger'
import { getCliEnv } from './cli-env'
import { IPC } from '../shared/types'
import type { RunOptions, NormalizedEvent, EnrichedError } from '../shared/types'
import { isValidSessionId, validateProjectPath, verifyBinary, escapeAppleScript, isValidHttpUrl } from './security'

const DEBUG_MODE = process.env.CLUI_DEBUG === '1'
const SPACES_DEBUG = DEBUG_MODE || process.env.CLUI_SPACES_DEBUG === '1'
Expand All @@ -31,7 +31,7 @@ const controlPlane = new ControlPlane(INTERACTIVE_PTY)
// Keep native width fixed to avoid renderer animation vs setBounds race.
// The UI itself still launches in compact mode; extra width is transparent/click-through.
const BAR_WIDTH = 1040
const PILL_HEIGHT = 720 // Fixed native window height — extra room for expanded UI + shadow buffers
const PILL_HEIGHT = 160 // Start compact — ResizeObserver dynamically adjusts to content height
const PILL_BOTTOM_MARGIN = 24

// ─── Broadcast to renderer ───
Expand Down Expand Up @@ -157,39 +157,6 @@ function createWindow(): void {
}
}

function showWindow(source = 'unknown'): void {
if (!mainWindow) return
const toggleId = ++toggleSequence

// Position on the display where the cursor currently is (not always primary)
const cursor = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursor)
const { width: sw, height: sh } = display.workAreaSize
const { x: dx, y: dy } = display.workArea
mainWindow.setBounds({
x: dx + Math.round((sw - BAR_WIDTH) / 2),
y: dy + sh - PILL_HEIGHT - PILL_BOTTOM_MARGIN,
width: BAR_WIDTH,
height: PILL_HEIGHT,
})

// Always re-assert space membership — the flag can be lost after hide/show cycles
// and must be set before show() so the window joins the active Space, not its
// last-known Space.
mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })

if (SPACES_DEBUG) {
log(`[spaces] showWindow#${toggleId} source=${source} move-to-display id=${display.id}`)
snapshotWindowState(`showWindow#${toggleId} pre-show`)
}
// As an accessory app (app.dock.hide), show() + focus gives keyboard
// without deactivating the active app — hover preserved everywhere.
mainWindow.show()
mainWindow.webContents.focus()
broadcast(IPC.WINDOW_SHOWN)
if (SPACES_DEBUG) scheduleToggleSnapshots(toggleId, 'show')
}

function toggleWindow(source = 'unknown'): void {
if (!mainWindow) return
const toggleId = ++toggleSequence
Expand All @@ -198,20 +165,44 @@ function toggleWindow(source = 'unknown'): void {
snapshotWindowState(`toggle#${toggleId} pre`)
}

// Pure toggle: visible → hide, not visible → show. No focus-based branching.
if (mainWindow.isVisible()) {
mainWindow.hide()
if (SPACES_DEBUG) scheduleToggleSnapshots(toggleId, 'hide')
} else {
showWindow(source)
// Show window where it was last positioned (user controls placement via drag).
// Only reposition if this is the first show (window at default position).
if (SPACES_DEBUG) {
snapshotWindowState(`toggle#${toggleId} pre-show`)
}
// As an accessory app (app.dock.hide), show() + focus gives keyboard
// without deactivating the active app — hover preserved everywhere.
mainWindow.show()
mainWindow.webContents.focus()
broadcast(IPC.WINDOW_SHOWN)
if (SPACES_DEBUG) scheduleToggleSnapshots(toggleId, 'show')
}
}

// ─── Resize ───
// Fixed-height mode: ignore renderer resize events to prevent jank.
// The native window stays at PILL_HEIGHT; all expand/collapse happens inside the renderer.

ipcMain.on(IPC.RESIZE_HEIGHT, () => {
// No-op — fixed height window, no dynamic resize
ipcMain.on(IPC.RESIZE_HEIGHT, (_event, height: number) => {
if (!mainWindow || mainWindow.isDestroyed()) return

// Cap at screen work area height (minus menu bar, dock, etc.)
const cursor = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(cursor)
const maxHeight = display.workAreaSize.height - 20

const safeHeight = Math.max(120, Math.min(Math.round(height), maxHeight))
const [x, y] = mainWindow.getPosition()
const currentHeight = mainWindow.getBounds().height

// Anchor bottom edge: shift y so the bottom of the window stays in place
const newY = y + currentHeight - safeHeight
mainWindow.setBounds({ x, y: newY, width: BAR_WIDTH, height: safeHeight })
})

ipcMain.on(IPC.SET_WINDOW_WIDTH, () => {
Expand Down Expand Up @@ -239,6 +230,19 @@ ipcMain.on(IPC.SET_IGNORE_MOUSE_EVENTS, (event, ignore: boolean, options?: { for
}
})

// ─── JS-based window drag (bypasses setIgnoreMouseEvents conflict) ───

ipcMain.handle(IPC.GET_WINDOW_POSITION, () => {
if (!mainWindow || mainWindow.isDestroyed()) return { x: 0, y: 0 }
const [x, y] = mainWindow.getPosition()
return { x, y }
})

ipcMain.on(IPC.MOVE_WINDOW, (_event, x: number, y: number) => {
if (!mainWindow || mainWindow.isDestroyed()) return
mainWindow.setPosition(Math.round(x), Math.round(y))
})

// ─── IPC Handlers (typed, strict) ───

ipcMain.handle(IPC.START, async () => {
Expand All @@ -247,18 +251,18 @@ ipcMain.handle(IPC.START, async () => {

let version = 'unknown'
try {
version = execSync('claude -v', { encoding: 'utf-8', timeout: 5000, env: getCliEnv() }).trim()
version = execSync('claude -v', { encoding: 'utf-8', timeout: 5000 }).trim()
} catch {}

let auth: { email?: string; subscriptionType?: string; authMethod?: string } = {}
try {
const raw = execSync('claude auth status', { encoding: 'utf-8', timeout: 5000, env: getCliEnv() }).trim()
const raw = execSync('claude auth status', { encoding: 'utf-8', timeout: 5000 }).trim()
auth = JSON.parse(raw)
} catch {}

let mcpServers: string[] = []
try {
const raw = execSync('claude mcp list', { encoding: 'utf-8', timeout: 5000, env: getCliEnv() }).trim()
const raw = execSync('claude mcp list', { encoding: 'utf-8', timeout: 5000 }).trim()
if (raw) mcpServers = raw.split('\n').filter(Boolean)
} catch {}

Expand Down Expand Up @@ -349,7 +353,8 @@ ipcMain.handle(IPC.RESPOND_PERMISSION, (_event, { tabId, questionId, optionId }:
ipcMain.handle(IPC.LIST_SESSIONS, async (_e, projectPath?: string) => {
log(`IPC LIST_SESSIONS ${projectPath ? `(path=${projectPath})` : ''}`)
try {
const cwd = projectPath || process.cwd()
const rawCwd = projectPath || process.cwd()
const cwd = validateProjectPath(rawCwd) || process.cwd()
// Claude stores project sessions at ~/.claude/projects/<encoded-path>/
// Path encoding: replace all '/' with '-' (leading '/' becomes leading '-')
const encodedPath = cwd.replace(/\//g, '-')
Expand Down Expand Up @@ -430,8 +435,16 @@ ipcMain.handle(IPC.LOAD_SESSION, async (_e, arg: { sessionId: string; projectPat
const sessionId = typeof arg === 'string' ? arg : arg.sessionId
const projectPath = typeof arg === 'string' ? undefined : arg.projectPath
log(`IPC LOAD_SESSION ${sessionId}${projectPath ? ` (path=${projectPath})` : ''}`)

// Security: validate sessionId format
if (!isValidSessionId(sessionId)) {
log(`LOAD_SESSION: rejected invalid sessionId: ${sessionId}`)
return []
}

try {
const cwd = projectPath || process.cwd()
const rawCwd = projectPath || process.cwd()
const cwd = validateProjectPath(rawCwd) || process.cwd()
const encodedPath = cwd.replace(/\//g, '-')
const filePath = join(homedir(), '.claude', 'projects', encodedPath, `${sessionId}.jsonl`)
if (!existsSync(filePath)) return []
Expand Down Expand Up @@ -490,7 +503,7 @@ ipcMain.handle(IPC.SELECT_DIRECTORY, async () => {
// Unparented avoids modal dimming on the transparent overlay.
// Activation is fine here — user is actively interacting with CLUI.
if (process.platform === 'darwin') app.focus()
const options = { properties: ['openDirectory'] as const }
const options: Electron.OpenDialogOptions = { properties: ['openDirectory'] }
const result = process.platform === 'darwin'
? await dialog.showOpenDialog(options)
: await dialog.showOpenDialog(mainWindow, options)
Expand All @@ -499,8 +512,11 @@ ipcMain.handle(IPC.SELECT_DIRECTORY, async () => {

ipcMain.handle(IPC.OPEN_EXTERNAL, async (_event, url: string) => {
try {
// Only allow http(s) links from markdown content.
if (!/^https?:\/\//i.test(url)) return false
// Security: validate URL using centralized validator
if (!isValidHttpUrl(url)) {
log(`OPEN_EXTERNAL: rejected non-HTTP URL: ${url}`)
return false
}
await shell.openExternal(url)
return true
} catch {
Expand All @@ -512,7 +528,7 @@ ipcMain.handle(IPC.ATTACH_FILES, async () => {
if (!mainWindow) return null
// macOS: activate app so unparented dialog appears on top
if (process.platform === 'darwin') app.focus()
const options = {
const options: Electron.OpenDialogOptions = {
properties: ['openFile', 'multiSelections'],
filters: [
{ name: 'All Files', extensions: ['*'] },
Expand Down Expand Up @@ -688,6 +704,16 @@ ipcMain.handle(IPC.TRANSCRIBE_AUDIO, async (_event, audioBase64: string) => {
}
}

// Security: verify the whisper binary is in a trusted location and not tampered
const binCheck = verifyBinary(whisperBin)
if (!binCheck.trusted) {
log(`Whisper binary rejected: ${whisperBin} — ${binCheck.reason}`)
return {
error: `Whisper binary at ${whisperBin} failed verification: ${binCheck.reason}. Reinstall with: brew install whisper-cli`,
transcript: null,
}
}

const isWhisperCpp = whisperBin.includes('whisper-cli')

// Find model file — prefer multilingual (auto-detect language) over .en (English-only)
Expand Down Expand Up @@ -810,13 +836,26 @@ ipcMain.handle(IPC.OPEN_IN_TERMINAL, (_event, arg: string | null | { sessionId?:
projectPath = arg.projectPath && arg.projectPath !== '~' ? arg.projectPath : process.cwd()
}

// Escape for AppleScript: double quotes → backslash-escaped, backslashes doubled
const projectDir = projectPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
// Security: validate sessionId format (must be UUID v4 if provided)
if (sessionId && !isValidSessionId(sessionId)) {
log(`OPEN_IN_TERMINAL: rejected invalid sessionId format: ${sessionId}`)
return false
}

// Security: validate and sanitize project path
const validatedPath = validateProjectPath(projectPath)
if (!validatedPath) {
log(`OPEN_IN_TERMINAL: rejected invalid project path: ${projectPath}`)
return false
}

// Security: use escapeAppleScript to prevent injection via osascript
const escapedPath = escapeAppleScript(validatedPath)
let cmd: string
if (sessionId) {
cmd = `cd \\"${projectDir}\\" && ${claudeBin} --resume ${sessionId}`
cmd = `cd \\"${escapedPath}\\" && ${claudeBin} --resume ${sessionId}`
} else {
cmd = `cd \\"${projectDir}\\" && ${claudeBin}`
cmd = `cd \\"${escapedPath}\\" && ${claudeBin}`
}

const script = `tell application "Terminal"
Expand Down Expand Up @@ -868,43 +907,16 @@ nativeTheme.on('updated', () => {
broadcast(IPC.THEME_CHANGED, nativeTheme.shouldUseDarkColors)
})

// ─── Permission Preflight ───
// Request all required macOS permissions upfront on first launch so the user
// is never interrupted mid-session by a permission prompt.

async function requestPermissions(): Promise<void> {
if (process.platform !== 'darwin') return

// ── Microphone (for voice input via Whisper) ──
try {
const micStatus = systemPreferences.getMediaAccessStatus('microphone')
if (micStatus === 'not-determined') {
await systemPreferences.askForMediaAccess('microphone')
}
} catch (err: any) {
log(`Permission preflight: microphone check failed — ${err.message}`)
}

// ── Accessibility (for global ⌥+Space shortcut) ──
// globalShortcut works without it on modern macOS; Cmd+Shift+K is always the fallback.
// Screen Recording: not requested upfront — macOS 15 Sequoia shows an alarming
// "bypass private window picker" dialog. Let the OS prompt naturally if/when
// the screenshot feature is actually used.
}

// ─── App Lifecycle ───

app.whenReady().then(async () => {
app.whenReady().then(() => {
// macOS: become an accessory app. Accessory apps can have key windows (keyboard works)
// without deactivating the currently active app (hover preserved in browsers).
// This is how Spotlight, Alfred, Raycast work.
if (process.platform === 'darwin' && app.dock) {
app.dock.hide()
}

// Request permissions upfront so the user is never interrupted mid-session.
await requestPermissions()

// Skill provisioning — non-blocking, streams status to renderer
ensureSkills((status: SkillStatus) => {
log(`Skill ${status.name}: ${status.state}${status.error ? ` — ${status.error}` : ''}`)
Expand Down Expand Up @@ -956,16 +968,12 @@ app.whenReady().then(async () => {
tray.on('click', () => toggleWindow('tray click'))
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Show Clui CC', click: () => showWindow('tray menu') },
{ label: 'Show Clui CC', click: () => toggleWindow('tray menu Show Clui CC') },
{ label: 'Quit', click: () => { app.quit() } },
])
)

// app 'activate' fires when macOS brings the app to the foreground (e.g. after
// webContents.focus() triggers applicationDidBecomeActive on some macOS versions).
// Using showWindow here instead of toggleWindow prevents the re-entry race where
// a summon immediately hides itself because activate fires mid-show.
app.on('activate', () => showWindow('app activate'))
app.on('activate', () => toggleWindow('app activate'))
})

app.on('will-quit', () => {
Expand Down
Loading