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
3 changes: 2 additions & 1 deletion src/main/fileWatcher.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execFile } from 'child_process'
import { log } from './logger'
import { watch, type FSWatcher } from 'chokidar'
import { BrowserWindow } from 'electron'

Expand Down Expand Up @@ -42,7 +43,7 @@ function runGitStatus(cwd: string): Promise<ChangedFile[]> {
return new Promise((resolve) => {
execFile('git', ['status', '--porcelain'], { cwd }, (err, stdout) => {
if (err) {
console.warn('[fileWatcher] git status failed for', cwd, (err as Error).message)
log.warn('fileWatcher', `git status failed for ${cwd}: ${(err as Error).message}`)
resolve([])
return
}
Expand Down
7 changes: 4 additions & 3 deletions src/main/github.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execFile } from 'child_process'
import { log } from './logger'
import type { GitHubRepo, GitHubPR, GitHubIssue } from '../shared/types'

export type { GitHubRepo, GitHubPR, GitHubIssue }
Expand All @@ -7,7 +8,7 @@ export function getGitHubRepo(cwd: string): Promise<GitHubRepo | null> {
return new Promise((resolve) => {
execFile('git', ['remote', 'get-url', 'origin'], { cwd }, (err, stdout) => {
if (err) {
console.warn('[github] Failed to get remote URL:', (err as Error).message)
log.warn('github', `Failed to get remote URL: ${(err as Error).message}`)
resolve(null)
return
}
Expand Down Expand Up @@ -39,7 +40,7 @@ export async function listPullRequests(
{ cwd },
(err, stdout) => {
if (err) {
console.warn('[github] gh pr list failed:', (err as Error).message)
log.warn('github', `gh pr list failed: ${(err as Error).message}`)
reject(err)
return
}
Expand Down Expand Up @@ -99,7 +100,7 @@ export async function listIssues(
{ cwd },
(err, stdout) => {
if (err) {
console.warn('[github] gh issue list failed:', (err as Error).message)
log.warn('github', `gh issue list failed: ${(err as Error).message}`)
reject(err)
return
}
Expand Down
42 changes: 32 additions & 10 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { app, shell, BrowserWindow, ipcMain, dialog } from 'electron'
import { autoUpdater } from 'electron-updater'
import { log } from './logger'

if (process.platform === 'linux') {
app.commandLine.appendSwitch('no-sandbox')
Expand Down Expand Up @@ -200,7 +201,7 @@ function registerSessionHandlers(ipc: typeof ipcMain, window: BrowserWindow): vo
}
context = snippets.join('\n\n')
} catch (err) {
console.warn('[summary] Failed to read transcript:', (err as Error).message)
log.warn('summary', `Failed to read transcript: ${(err as Error).message}`)
}

if (!context) return ''
Expand All @@ -214,7 +215,7 @@ function registerSessionHandlers(ipc: typeof ipcMain, window: BrowserWindow): vo
{ env: getEnv(), timeout: 15000 },
(err, stdout) => {
if (err || !stdout) {
if (err) console.warn('[summary] Claude CLI failed:', (err as Error).message)
if (err) log.warn('summary', `Claude CLI failed: ${(err as Error).message}`)
resolve('')
return
}
Expand Down Expand Up @@ -258,7 +259,7 @@ function registerFileHandlers(ipc: typeof ipcMain): void {
} else {
execFile('git', ['diff', 'HEAD', '--', filePath], { cwd }, (err, stdout) => {
if (err && !stdout) {
console.warn('[diff] git diff failed for', filePath, (err as Error).message)
log.warn('diff', `git diff failed for ${filePath}: ${(err as Error).message}`)
resolve('')
return
}
Expand Down Expand Up @@ -371,6 +372,18 @@ function registerUpdateHandlers(ipc: typeof ipcMain): void {
ipc.on('install-update', () => {
autoUpdater.quitAndInstall()
})
ipc.on('check-for-updates', () => {
if (is.dev) {
log.warn('updater', 'Auto-update is not available in development mode')
mainWindow?.webContents.send('update-status', {
status: 'error',
message: 'Auto-update is not available in development mode'
})
return
}
autoUpdater.checkForUpdates()
})
ipc.handle('get-logs', () => log.getHistory())
}

// ─── App Lifecycle ────────────────────────────────────────────────────
Expand All @@ -383,6 +396,8 @@ app.whenReady().then(() => {
})

createWindow()
log.setWindow(mainWindow!)
log.info('app', `Konductor started (${is.dev ? 'dev' : 'production'})`)

registerStateHandlers(ipcMain)
registerSessionHandlers(ipcMain, mainWindow!)
Expand All @@ -398,30 +413,37 @@ app.whenReady().then(() => {
autoUpdater.autoDownload = true
autoUpdater.autoInstallOnAppQuit = true

autoUpdater.on('checking-for-update', () => console.log('[updater] Checking for update…'))
autoUpdater.on('checking-for-update', () => log.info('updater', 'Checking for update…'))
autoUpdater.on('update-available', (info) => {
console.log(`[updater] Update available: ${info.version}`)
log.info('updater', `Update available: ${info.version}`)
mainWindow?.webContents.send('update-status', {
status: 'available',
version: info.version
})
})
autoUpdater.on('update-not-available', (info) =>
console.log(`[updater] Up to date (${info.version})`)
log.info('updater', `Up to date (${info.version})`)
)
autoUpdater.on('download-progress', (p) =>
console.log(`[updater] Downloading: ${Math.round(p.percent)}%`)
log.info('updater', `Downloading: ${Math.round(p.percent)}%`)
)
autoUpdater.on('update-downloaded', (info) => {
console.log(`[updater] Update downloaded: ${info.version} — will install on quit`)
log.info('updater', `Update downloaded: ${info.version} — will install on quit`)
mainWindow?.webContents.send('update-status', {
status: 'ready',
version: info.version
})
})
autoUpdater.on('error', (err) => console.error('[updater] Error:', err.message))
autoUpdater.on('error', (err) => {
log.error('updater', err.message)
mainWindow?.webContents.send('update-status', {
status: 'error',
message: err.message
})
})

autoUpdater.checkForUpdatesAndNotify()
autoUpdater.checkForUpdates()
setInterval(() => autoUpdater.checkForUpdates(), 30 * 60 * 1000)
}

app.on('activate', () => {
Expand Down
98 changes: 98 additions & 0 deletions src/main/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { appendFileSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'fs'
import { join } from 'path'
import { homedir } from 'os'
import type { BrowserWindow } from 'electron'

export interface LogEntry {
timestamp: number
level: 'info' | 'warn' | 'error'
category: string
message: string
}

const LOG_DIR = join(homedir(), '.konductor')
const LOG_FILE = join(LOG_DIR, 'app.log')
const MAX_FILE_SIZE = 512 * 1024 // 512 KB

class Logger {
private window: BrowserWindow | null = null

constructor() {
try {
mkdirSync(LOG_DIR, { recursive: true })
} catch {
// best-effort
}
}

setWindow(win: BrowserWindow): void {
this.window = win
}

info(category: string, message: string): void {
this.write('info', category, message)
console.log(`[${category}] ${message}`)
}

warn(category: string, message: string): void {
this.write('warn', category, message)
console.warn(`[${category}] ${message}`)
}

error(category: string, message: string): void {
this.write('error', category, message)
console.error(`[${category}] ${message}`)
}

getHistory(): LogEntry[] {
try {
const raw = readFileSync(LOG_FILE, 'utf-8')
const entries: LogEntry[] = []
for (const line of raw.split('\n')) {
if (!line) continue
try {
entries.push(JSON.parse(line))
} catch {
// skip malformed lines
}
}
return entries
} catch {
return []
}
}

private write(level: LogEntry['level'], category: string, message: string): void {
const entry: LogEntry = { timestamp: Date.now(), level, category, message }

try {
this.rotateIfNeeded()
appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n')
} catch {
// best-effort
}

try {
this.window?.webContents.send('app-log', entry)
} catch {
// window may have been destroyed
}
}

private rotateIfNeeded(): void {
try {
const stats = statSync(LOG_FILE)
if (stats.size > MAX_FILE_SIZE) {
// Keep the second half of the file
const raw = readFileSync(LOG_FILE, 'utf-8')
const lines = raw.split('\n')
const half = Math.floor(lines.length / 2)
writeFileSync(LOG_FILE, lines.slice(half).join('\n'))
}
} catch {
// file doesn't exist yet, that's fine
}
}
}

export const log = new Logger()
14 changes: 8 additions & 6 deletions src/main/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { BrowserWindow } from 'electron'
import { is } from '@electron-toolkit/utils'
import { createFileWatcher, FileWatcher } from './fileWatcher'
import { shellQuote } from './shellEscape'
import { log } from './logger'
import { ScrollbackBuffer } from './ringBuffer'

const DEV_PLUGIN_PATH = join(__dirname, '../../claude-code-plugin')
Expand Down Expand Up @@ -149,7 +150,7 @@ function spawnClaude(
args.push('--prompt', prompt)
}

console.log(`[session] spawn: ${getClaudePath()} ${args.join(' ')} cwd=${cwd}`)
log.info('session', `spawn: ${getClaudePath()} ${args.join(' ')} cwd=${cwd}`)

return nodePty.spawn(getClaudePath(), args, {
name: 'xterm-256color',
Expand Down Expand Up @@ -178,8 +179,9 @@ export function createSession(
const envScript = opts?.envScript ?? detectEnvScript(cwd)
const env = envScript ? getProjectEnv(envScript, cwd) : undefined

console.log(
`[session] createSession id=${id} claude=${claudeSessionId} resume=${resume} cwd=${cwd}`
log.info(
'session',
`createSession id=${id} claude=${claudeSessionId} resume=${resume} cwd=${cwd}`
)

const pty = spawnClaude(cwd, claudeSessionId, name, resume, opts?.prompt, env)
Expand All @@ -202,7 +204,7 @@ export function createSession(
})

pty.onExit(({ exitCode }) => {
console.log(`[session] pty-exit id=${id} claude=${claudeSessionId} exitCode=${exitCode}`)
log.info('session', `pty-exit id=${id} claude=${claudeSessionId} exitCode=${exitCode}`)
entry.alive = false
if (!window.isDestroyed()) {
window.webContents.send('pty-exit', { sessionId: id, exitCode })
Expand Down Expand Up @@ -299,14 +301,14 @@ export function ensurePluginInstalled(): void {
// Add the marketplace (idempotent — no-ops if already added)
execFileSync(claude, ['plugin', 'marketplace', 'add', MARKETPLACE_REPO], opts)
} catch (err) {
console.warn('[plugin] Failed to add marketplace:', (err as Error).message)
log.warn('plugin', `Failed to add marketplace: ${(err as Error).message}`)
}

try {
// Install the plugin (idempotent — no-ops if already installed)
execFileSync(claude, ['plugin', 'install', MARKETPLACE_PLUGIN], opts)
} catch (err) {
console.warn('[plugin] Failed to install plugin:', (err as Error).message)
log.warn('plugin', `Failed to install plugin: ${(err as Error).message}`)
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/main/worktree.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execFile } from 'child_process'
import { log } from './logger'
import { join } from 'path'
import { mkdir } from 'fs/promises'
import type { WorktreeInfo, BranchDetail, PrInfo, PrState, BranchFile } from '../shared/types'
Expand Down Expand Up @@ -30,7 +31,7 @@ function ghSafe(args: string[], cwd: string): Promise<string> {
return new Promise((resolve) => {
execFile('gh', args, { cwd }, (err, stdout) => {
if (err) {
console.warn('[gh]', args.slice(0, 3).join(' '), 'failed:', (err as Error).message)
log.warn('gh', `${args.slice(0, 3).join(' ')} failed: ${(err as Error).message}`)
resolve('')
} else {
resolve((stdout || '').trim())
Expand Down
23 changes: 22 additions & 1 deletion src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ import type { PersistedState } from '../main/store'
import type { WorktreeInfo, BranchDetail, BranchFile } from '../shared/types'
import type { GitHubRepo, GitHubPR, GitHubIssue } from '../shared/types'

export type UpdateStatus = { status: 'available' | 'ready'; version: string }
export type UpdateStatus =
| { status: 'available' | 'ready'; version: string }
| { status: 'error'; message: string }

export interface LogEntry {
timestamp: number
level: 'info' | 'warn' | 'error'
category: string
message: string
}

export interface KonductorAPI {
loadState: () => Promise<PersistedState>
Expand Down Expand Up @@ -51,6 +60,9 @@ export interface KonductorAPI {
generateSummary: (cwd: string, claudeSessionId: string) => Promise<string>
onUpdateStatus: (cb: (status: UpdateStatus) => void) => () => void
installUpdate: () => void
checkForUpdates: () => void
getLogs: () => Promise<LogEntry[]>
onAppLog: (cb: (entry: LogEntry) => void) => () => void
onSessionActivity: (
cb: (claudeSessionId: string, state: ActivityState, tool: string, summary: string) => void
) => () => void
Expand Down Expand Up @@ -169,6 +181,15 @@ const api: KonductorAPI = {
return () => ipcRenderer.removeListener('update-status', handler)
},
installUpdate: () => ipcRenderer.send('install-update'),
checkForUpdates: () => ipcRenderer.send('check-for-updates'),
getLogs: () => ipcRenderer.invoke('get-logs'),
onAppLog: (cb: (entry: LogEntry) => void) => {
const handler = (_event: Electron.IpcRendererEvent, entry: LogEntry): void => {
cb(entry)
}
ipcRenderer.on('app-log', handler)
return () => ipcRenderer.removeListener('app-log', handler)
},

onSessionActivity: (
cb: (claudeSessionId: string, state: ActivityState, tool: string, summary: string) => void
Expand Down
Loading
Loading