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
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"atom-file-icons": "^1.0.3",
"node-pty": "^1.1.0",
"react": "^19.0.0",
"react-arborist": "^3.4.3",
Expand Down
87 changes: 87 additions & 0 deletions src/main/claudeScanner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* ClaudeScanner — side-channel scanner for PTY output.
* Detects Claude CLI session start/stop, model name, and cost output.
* Never blocks or modifies the PTY data stream.
*/
class ClaudeScanner {
constructor(ptyId, emitFn) {
this.ptyId = ptyId
this.emit = emitFn
this.buffer = ''
this.maxBuffer = 2048
this.active = false
this.model = null
this.cost = null
}

scan(data) {
this.buffer += data
if (this.buffer.length > this.maxBuffer) {
this.buffer = this.buffer.slice(-this.maxBuffer)
}

// Detect Claude startup — look for common banner patterns
if (!this.active) {
// Claude Code banner: "╭" or "Claude Code" or model line like "claude-3-5-sonnet"
if (
/Claude\s+(Code|[\d.]+)/i.test(this.buffer) ||
/\u256D/.test(data) && /claude/i.test(this.buffer)
) {
this.active = true
this.emit('claude:session-change', { ptyId: this.ptyId, active: true })
this._checkModel()
}
}

if (this.active) {
this._checkModel()
this._checkCost()
this._checkExit(data)
}
}

_checkModel() {
// Match model patterns like "claude-opus-4-6", "claude-sonnet-4-6", etc.
const modelMatch = this.buffer.match(/(claude-[\w-]+[\d][\w-]*)/i)
if (modelMatch && modelMatch[1] !== this.model) {
this.model = modelMatch[1]
this.emit('claude:model-update', { ptyId: this.ptyId, model: this.model })
}
}

_checkCost() {
// Match cost patterns like "$0.05", "Total cost: $1.23"
const costMatch = this.buffer.match(/\$(\d+\.\d{2,})/g)
if (costMatch) {
const latest = costMatch[costMatch.length - 1]
if (latest !== this.cost) {
this.cost = latest
this.emit('claude:cost-update', { ptyId: this.ptyId, cost: this.cost })
}
}
}

_checkExit(data) {
// Detect Claude exit — shell prompt returns ($ or > at end of line after newline)
// This is heuristic: look for prompt-like patterns after a period of Claude being active
if (/\n[^\n]*[\$#>]\s*$/.test(data) && !/claude/i.test(data)) {
// Only trigger if recent buffer doesn't contain Claude-like output
const recentChunk = this.buffer.slice(-200)
if (!/[╭╰│─]/.test(recentChunk) && !/Claude/i.test(recentChunk.slice(-80))) {
this.active = false
this.model = null
this.cost = null
this.emit('claude:session-change', { ptyId: this.ptyId, active: false })
}
}
}

reset() {
this.buffer = ''
this.active = false
this.model = null
this.cost = null
}
}

module.exports = { ClaudeScanner }
123 changes: 123 additions & 0 deletions src/main/gitHandlers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
const { execFile } = require('child_process')
const path = require('path')

// Allowlisted git subcommands — only these can be executed
const ALLOWED_SUBCOMMANDS = new Set([
'status', 'diff', 'show', 'add', 'restore', 'rev-parse'
])

function runGit(args, cwd, timeout = 10000) {
return new Promise((resolve, reject) => {
const subcommand = args[0]
if (!ALLOWED_SUBCOMMANDS.has(subcommand)) {
return reject(new Error(`Git subcommand not allowed: ${subcommand}`))
}

execFile('git', args, { cwd, timeout, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
if (err) {
reject(new Error(stderr || err.message))
} else {
resolve(stdout)
}
})
})
}

function parseStatusLine(line) {
if (line.length < 4) return null
const x = line[0] // staged status
const y = line[1] // unstaged status
const filePath = line.slice(3)

let status = 'modified'
if (x === '?' && y === '?') status = 'untracked'
else if (x === 'A' || y === 'A') status = 'added'
else if (x === 'D' || y === 'D') status = 'deleted'
else if (x === 'R' || y === 'R') status = 'renamed'

return {
path: filePath,
staged: x !== ' ' && x !== '?',
status,
x,
y
}
}

function registerGitHandlers(ipcMain, isPathAllowed) {
// Check if cwd is inside a git repo
ipcMain.handle('git:is-repo', async (event, { cwd }) => {
try {
await runGit(['rev-parse', '--is-inside-work-tree'], cwd)
return { isRepo: true }
} catch {
return { isRepo: false }
}
})

// Get parsed git status
ipcMain.handle('git:status', async (event, { cwd }) => {
try {
const output = await runGit(['status', '--porcelain=v1'], cwd)
const files = output
.split('\n')
.filter(Boolean)
.map(parseStatusLine)
.filter(Boolean)
return { files, error: null }
} catch (err) {
return { files: [], error: err.message }
}
})

// Get raw diff output for a file
ipcMain.handle('git:diff', async (event, { cwd, filePath, staged }) => {
try {
const args = staged
? ['diff', '--cached', '--', filePath]
: ['diff', '--', filePath]
const output = await runGit(args, cwd)
return { diff: output, error: null }
} catch (err) {
return { diff: '', error: err.message }
}
})

// Get original file content from HEAD for MergeView
ipcMain.handle('git:diff-file', async (event, { cwd, filePath }) => {
try {
const output = await runGit(['show', `HEAD:${filePath}`], cwd)
return { content: output, error: null }
} catch (err) {
return { content: '', error: err.message }
}
})

// Stage a file
ipcMain.handle('git:stage', async (event, { cwd, filePath }) => {
if (!isPathAllowed(path.resolve(cwd, filePath))) {
return { error: 'Access denied: path outside allowed roots' }
}
try {
await runGit(['add', '--', filePath], cwd)
return { error: null }
} catch (err) {
return { error: err.message }
}
})

// Unstage a file
ipcMain.handle('git:unstage', async (event, { cwd, filePath }) => {
if (!isPathAllowed(path.resolve(cwd, filePath))) {
return { error: 'Access denied: path outside allowed roots' }
}
try {
await runGit(['restore', '--staged', '--', filePath], cwd)
return { error: null }
} catch (err) {
return { error: err.message }
}
})
}

module.exports = { registerGitHandlers }
Loading