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
20 changes: 20 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "konductor",
"description": "Session activity tracking for the Konductor multi-session manager",
"owner": {
"name": "kranklab"
},
"plugins": [
{
"name": "konductor",
"description": "Reports busy/idle state from Claude Code sessions to the Konductor host app",
"version": "0.1.0",
"author": {
"name": "kranklab"
},
"source": "./claude-code-plugin",
"category": "development"
}
]
}
10 changes: 10 additions & 0 deletions claude-code-plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "konductor",
"version": "0.1.0",
"description": "Reports busy/idle state from Claude Code sessions to the Konductor host app.",
"author": {
"name": "kranklab"
},
"repository": "https://github.com/kranklab/konductor",
"license": "MIT"
}
26 changes: 26 additions & 0 deletions claude-code-plugin/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"hooks": {
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/state-tracker.sh"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/state-tracker.sh"
}
]
}
]
}
}
41 changes: 41 additions & 0 deletions claude-code-plugin/scripts/state-tracker.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/bin/bash
# Konductor session state tracker
# Called by Claude Code hooks to report busy/idle status.
# Writes JSON state to $KONDUCTOR_STATE_DIR/<session_id>.json
# so the host Electron app can watch for changes.

set -euo pipefail

STATE_DIR="${KONDUCTOR_STATE_DIR:-/tmp/konductor-state}"
mkdir -p "$STATE_DIR"

INPUT=$(cat)
EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // empty')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')

[ -z "$EVENT" ] || [ -z "$SESSION_ID" ] && exit 0

STATE_FILE="$STATE_DIR/$SESSION_ID.json"

case "$EVENT" in
UserPromptSubmit)
STATE="working"
TOOL=""
;;
Stop)
STATE="waiting"
TOOL=""
;;
*)
exit 0
;;
esac

jq -n \
--arg state "$STATE" \
--arg tool "$TOOL" \
--arg event "$EVENT" \
--arg session "$SESSION_ID" \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{state: $state, tool: $tool, event: $event, session_id: $session, timestamp: $ts}' \
> "$STATE_FILE"
46 changes: 46 additions & 0 deletions src/main/activityWatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { watch, type FSWatcher } from 'chokidar'
import { readFile, mkdir } from 'fs/promises'
import { basename } from 'path'
import { BrowserWindow } from 'electron'
import { sessionStateDir } from './sessionManager'

export type ActivityState = 'working' | 'waiting' | 'ready'

let watcher: FSWatcher | null = null

export function startActivityWatcher(window: BrowserWindow): void {
mkdir(sessionStateDir, { recursive: true }).then(() => {
watcher = watch(sessionStateDir, {
persistent: true,
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 }
})

const handleFile = async (filePath: string): Promise<void> => {
if (!filePath.endsWith('.json')) return
try {
const raw = await readFile(filePath, 'utf-8')
const data = JSON.parse(raw)
const claudeSessionId = basename(filePath, '.json')
if (!window.isDestroyed()) {
window.webContents.send('session-activity', {
claudeSessionId,
state: data.state as ActivityState,
tool: data.tool || '',
timestamp: data.timestamp
})
}
} catch {
// File may be mid-write; ignore
}
}

watcher.on('add', handleFile)
watcher.on('change', handleFile)
})
}

export function stopActivityWatcher(): void {
watcher?.close()
watcher = null
}
3 changes: 3 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getSessionChanges
} from './sessionManager'
import { loadState, saveState, type PersistedState } from './store'
import { startActivityWatcher, stopActivityWatcher } from './activityWatcher'
import {
listWorktrees,
createWorktree,
Expand Down Expand Up @@ -199,6 +200,7 @@ app.whenReady().then(() => {
})

createWindow()
startActivityWatcher(mainWindow!)

if (!is.dev) {
autoUpdater.autoDownload = true
Expand Down Expand Up @@ -236,6 +238,7 @@ app.whenReady().then(() => {
})

app.on('window-all-closed', () => {
stopActivityWatcher()
killAllSessions()
if (process.platform !== 'darwin') {
app.quit()
Expand Down
13 changes: 10 additions & 3 deletions src/main/sessionManager.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { randomUUID } from 'crypto'
import { execFileSync } from 'child_process'
import { tmpdir } from 'os'
import { join } from 'path'
import * as nodePty from 'node-pty'
import { BrowserWindow } from 'electron'
import { createFileWatcher, FileWatcher } from './fileWatcher'

const PLUGIN_PATH = join(__dirname, '../../claude-code-plugin')
const STATE_DIR = join(tmpdir(), 'konductor-state')

const MAX_SCROLLBACK_BYTES = 256 * 1024 // 256KB per session

export interface SessionEntry {
Expand Down Expand Up @@ -78,15 +83,15 @@ function spawnClaude(
resume: boolean
): nodePty.IPty {
const args = resume
? ['--resume', claudeSessionId]
: ['--session-id', claudeSessionId, '--name', name]
? ['--resume', claudeSessionId, '--plugin-dir', PLUGIN_PATH]
: ['--session-id', claudeSessionId, '--name', name, '--plugin-dir', PLUGIN_PATH]

return nodePty.spawn(getClaudePath(), args, {
name: 'xterm-256color',
cols: 80,
rows: 24,
cwd,
env: getEnv()
env: { ...getEnv(), KONDUCTOR_STATE_DIR: STATE_DIR }
})
}

Expand Down Expand Up @@ -200,3 +205,5 @@ export function getSessionChanges(sessionId: string): import('./fileWatcher').Ch
const entry = sessions.get(sessionId)
return entry?.watcher.getChanges() ?? []
}

export { STATE_DIR as sessionStateDir }
19 changes: 18 additions & 1 deletion src/preload/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { ChangedFile } from '../main/fileWatcher'
import type { ActivityState } from '../main/activityWatcher'
import type { SessionInfo } from '../main/sessionManager'
import type { PersistedState } from '../main/store'
import type { WorktreeInfo, BranchDetail } from '../main/worktree'
Expand Down Expand Up @@ -40,6 +41,9 @@ export interface KonductorAPI {
fetchPrune: (cwd: string) => Promise<void>
onUpdateStatus: (cb: (status: UpdateStatus) => void) => () => void
installUpdate: () => void
onSessionActivity: (
cb: (claudeSessionId: string, state: ActivityState, tool: string) => void
) => () => void
}

const api: KonductorAPI = {
Expand Down Expand Up @@ -122,7 +126,20 @@ const api: KonductorAPI = {
ipcRenderer.on('update-status', handler)
return () => ipcRenderer.removeListener('update-status', handler)
},
installUpdate: () => ipcRenderer.send('install-update')
installUpdate: () => ipcRenderer.send('install-update'),

onSessionActivity: (
cb: (claudeSessionId: string, state: ActivityState, tool: string) => void
) => {
const handler = (
_event: Electron.IpcRendererEvent,
payload: { claudeSessionId: string; state: ActivityState; tool: string }
): void => {
cb(payload.claudeSessionId, payload.state, payload.tool)
}
ipcRenderer.on('session-activity', handler)
return () => ipcRenderer.removeListener('session-activity', handler)
}
}

if (process.contextIsolated) {
Expand Down
10 changes: 9 additions & 1 deletion src/renderer/src/components/FocusView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,15 @@ export default function FocusView({
<div className="w-px h-4 bg-surface-border" />
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${session.alive ? 'bg-green-400' : 'bg-red-400'}`}
className={`w-2 h-2 rounded-full ${
!session.alive
? 'bg-red-400'
: session.activity === 'working'
? 'bg-green-400 animate-pulse'
: session.activity === 'waiting'
? 'bg-amber-400'
: 'bg-green-400'
}`}
/>
<span className="text-sm text-gray-300">{session.title}</span>
<span className="text-xs text-gray-500">{session.cwd}</span>
Expand Down
8 changes: 7 additions & 1 deletion src/renderer/src/components/SessionTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,13 @@ export default function SessionTile({
<div className="flex items-center gap-2 min-w-0">
<div
className={`w-2 h-2 rounded-full shrink-0 ${
session.alive ? 'bg-green-400' : 'bg-red-400'
!session.alive
? 'bg-red-400'
: session.activity === 'working'
? 'bg-green-400 animate-pulse'
: session.activity === 'waiting'
? 'bg-amber-400'
: 'bg-green-400'
}`}
/>
<span className="text-xs text-gray-400 truncate">{session.title}</span>
Expand Down
17 changes: 16 additions & 1 deletion src/renderer/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,23 @@ export default function Sidebar({
>
<div
className={`w-1.5 h-1.5 rounded-full shrink-0 mt-1 ${
session.alive ? 'bg-green-400' : 'bg-red-400'
!session.alive
? 'bg-red-400'
: session.activity === 'working'
? 'bg-green-400 animate-pulse'
: session.activity === 'waiting'
? 'bg-amber-400'
: 'bg-green-400'
}`}
title={
!session.alive
? 'Exited'
: session.activity === 'working'
? 'Working...'
: session.activity === 'waiting'
? 'Awaiting input'
: 'Ready'
}
/>
{isWorktree && (
<svg
Expand Down
18 changes: 14 additions & 4 deletions src/renderer/src/hooks/useSessions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { Terminal } from '@xterm/xterm'
import type { Project, Session } from '../types'
import type { Project, Session, ActivityState } from '../types'
import { TERM_THEME } from '../termTheme'

import '@xterm/xterm/css/xterm.css'
Expand Down Expand Up @@ -127,7 +127,8 @@ export function useSessions() {
title: meta.title,
terminal,
alive: true,
claudeSessionId: meta.claudeSessionId
claudeSessionId: meta.claudeSessionId,
activity: 'ready'
})
} catch {
// Session resume failed — skip it
Expand Down Expand Up @@ -185,7 +186,8 @@ export function useSessions() {
title: meta.title,
terminal,
alive: true,
claudeSessionId: meta.claudeSessionId
claudeSessionId: meta.claudeSessionId,
activity: 'ready'
})
}

Expand Down Expand Up @@ -288,9 +290,16 @@ export function useSessions() {
})
})

const unsubActivity = api.onSessionActivity((claudeSessionId: string, state: ActivityState) => {
setSessions((prev) =>
prev.map((s) => (s.claudeSessionId === claudeSessionId ? { ...s, activity: state } : s))
)
})

return () => {
unsubOutput()
unsubExit()
unsubActivity()
}
}, [ready])

Expand Down Expand Up @@ -347,7 +356,8 @@ export function useSessions() {
title,
terminal,
alive: true,
claudeSessionId
claudeSessionId,
activity: 'ready'
}

setSessions((prev) => [...prev, session])
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface Project {
cwd: string
}

export type ActivityState = 'working' | 'waiting' | 'ready'

export interface Session {
id: string
projectId: string
Expand All @@ -16,6 +18,7 @@ export interface Session {
terminal: Terminal
alive: boolean
claudeSessionId: string
activity: ActivityState
}

export interface ChangedFile {
Expand Down
Loading