diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..93fff75 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -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" + } + ] +} diff --git a/claude-code-plugin/.claude-plugin/plugin.json b/claude-code-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..328a20b --- /dev/null +++ b/claude-code-plugin/.claude-plugin/plugin.json @@ -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" +} diff --git a/claude-code-plugin/hooks/hooks.json b/claude-code-plugin/hooks/hooks.json new file mode 100644 index 0000000..927675f --- /dev/null +++ b/claude-code-plugin/hooks/hooks.json @@ -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" + } + ] + } + ] + } +} diff --git a/claude-code-plugin/scripts/state-tracker.sh b/claude-code-plugin/scripts/state-tracker.sh new file mode 100755 index 0000000..ddd48c8 --- /dev/null +++ b/claude-code-plugin/scripts/state-tracker.sh @@ -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/.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" diff --git a/src/main/activityWatcher.ts b/src/main/activityWatcher.ts new file mode 100644 index 0000000..7b947bb --- /dev/null +++ b/src/main/activityWatcher.ts @@ -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 => { + 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 +} diff --git a/src/main/index.ts b/src/main/index.ts index 4b56a71..bd4d433 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -20,6 +20,7 @@ import { getSessionChanges } from './sessionManager' import { loadState, saveState, type PersistedState } from './store' +import { startActivityWatcher, stopActivityWatcher } from './activityWatcher' import { listWorktrees, createWorktree, @@ -199,6 +200,7 @@ app.whenReady().then(() => { }) createWindow() + startActivityWatcher(mainWindow!) if (!is.dev) { autoUpdater.autoDownload = true @@ -236,6 +238,7 @@ app.whenReady().then(() => { }) app.on('window-all-closed', () => { + stopActivityWatcher() killAllSessions() if (process.platform !== 'darwin') { app.quit() diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index 076962d..82f173f 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -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 { @@ -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 } }) } @@ -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 } diff --git a/src/preload/index.ts b/src/preload/index.ts index 7281ba5..5b39f9b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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' @@ -40,6 +41,9 @@ export interface KonductorAPI { fetchPrune: (cwd: string) => Promise onUpdateStatus: (cb: (status: UpdateStatus) => void) => () => void installUpdate: () => void + onSessionActivity: ( + cb: (claudeSessionId: string, state: ActivityState, tool: string) => void + ) => () => void } const api: KonductorAPI = { @@ -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) { diff --git a/src/renderer/src/components/FocusView.tsx b/src/renderer/src/components/FocusView.tsx index 8130373..28e9f1d 100644 --- a/src/renderer/src/components/FocusView.tsx +++ b/src/renderer/src/components/FocusView.tsx @@ -88,7 +88,15 @@ export default function FocusView({
{session.title} {session.cwd} diff --git a/src/renderer/src/components/SessionTile.tsx b/src/renderer/src/components/SessionTile.tsx index 4a3e560..3b08087 100644 --- a/src/renderer/src/components/SessionTile.tsx +++ b/src/renderer/src/components/SessionTile.tsx @@ -95,7 +95,13 @@ export default function SessionTile({
{session.title} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index ac7ad40..1207712 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -208,8 +208,23 @@ export default function Sidebar({ >
{isWorktree && ( { + setSessions((prev) => + prev.map((s) => (s.claudeSessionId === claudeSessionId ? { ...s, activity: state } : s)) + ) + }) + return () => { unsubOutput() unsubExit() + unsubActivity() } }, [ready]) @@ -347,7 +356,8 @@ export function useSessions() { title, terminal, alive: true, - claudeSessionId + claudeSessionId, + activity: 'ready' } setSessions((prev) => [...prev, session]) diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index 75e2812..9dce956 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -8,6 +8,8 @@ export interface Project { cwd: string } +export type ActivityState = 'working' | 'waiting' | 'ready' + export interface Session { id: string projectId: string @@ -16,6 +18,7 @@ export interface Session { terminal: Terminal alive: boolean claudeSessionId: string + activity: ActivityState } export interface ChangedFile {