From 5aa49a11194c71875318567379f3e9cde2ec4d52 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Thu, 13 Aug 2026 13:47:38 -0400 Subject: [PATCH 1/6] feat(tools): user-defined right-column panels backed by a markdown-emitting script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool is a directory under /.harness/tools// with a tool.json (static title, so the panel has a header before the script has ever run) and an executable script whose stdout is rendered as markdown. The directory name becomes a `tool:` panel key, so custom tools flow through the existing per-repo order/visibility config and sit alongside the built-ins in the gear menu. Markdown maps onto the built-in panels' vocabulary rather than document typography — headings become the ChangedFilesPanel section header, list items become rows, and `harness:` links drive send-to-agent / open-file. A narrow contract is what keeps custom panels from drifting out of the design system. Tools are spawned directly on their shebang rather than through a login shell: `-ilc` cost 1-2s per run and would let rc-file chatter leak into stdout, which here is the panel body. useWatchedQuery grows `revalidateOnFileChange` and treats fallbackPollMs of 0 as "no polling", so a tool marked refresh:"manual" runs only on mount and on the refresh button — tool scripts routinely hit the network and the built-in panels' cadence would hammer an API. Co-Authored-By: Claude Opus 4.7 --- src/main/index.ts | 13 ++ src/main/repo-config.ts | 2 +- src/main/tools.test.ts | 106 ++++++++++++ src/main/tools.ts | 161 ++++++++++++++++++ src/renderer/build-backend.ts | 2 + src/renderer/components/CustomToolPanel.tsx | 99 +++++++++++ src/renderer/components/RightColumn.tsx | 42 ++++- .../components/RightColumnToolbar.tsx | 19 ++- src/renderer/components/SidebarMarkdown.tsx | 131 ++++++++++++++ src/renderer/hooks/useWatchedQuery.ts | 24 ++- src/renderer/types.ts | 5 + src/shared/state/repo-configs.test.ts | 26 +++ src/shared/state/repo-configs.ts | 38 ++++- src/shared/tools.ts | 34 ++++ 14 files changed, 682 insertions(+), 20 deletions(-) create mode 100644 src/main/tools.test.ts create mode 100644 src/main/tools.ts create mode 100644 src/renderer/components/CustomToolPanel.tsx create mode 100644 src/renderer/components/SidebarMarkdown.tsx create mode 100644 src/shared/tools.ts diff --git a/src/main/index.ts b/src/main/index.ts index 69b367dc4..4fb973ce5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -55,6 +55,7 @@ import { WorktreeWatcher } from './worktree-watcher' import { FileContentWatcher } from './file-content-watcher' import { SnoozeTimer } from './snooze-timer' import { getWeeklyStats } from './weekly-stats' +import { discoverTools, runTool } from './tools' import type { TerminalTab, PaneNode, PaneLeaf } from '../shared/state/terminals' import { getLeaves, mapLeaves } from '../shared/state/terminals' import { listWorktrees, listBranches, continueWorktree, isWorktreeDirty, defaultWorktreeDir, getChangedFiles, getFileDiff, getBranchCommits, getCommitDiff, getCommitMeta, getCommitChangedFiles, getCommitFileDiffSides, getCommitRangeChangedFiles, getCommitRangeFileDiffSides, getMainWorktreeStatus, prepareMainForMerge, mergeWorktreeLocally, getBranchSha, previewMergeConflicts, getBranchDiffStats, listAllFiles, listRecentCommitShas, readWorktreeFile, readWorktreeFileBinary, writeWorktreeFile, getFileDiffSides, getCurrentBranch, renameWorktreeBranch, symlinkClaudeSettings, pruneWorktrees, type MergeStrategy } from './worktree' @@ -1916,6 +1917,18 @@ function registerIpcHandlers(): void { return getBranchCommits(worktreePath) }) + transport.onRequest('tools:list', async (_ctx, worktreePath: string) => { + return discoverTools(worktreePath) + }) + + transport.onRequest('tools:run', async (_ctx, worktreePath: string, toolId: string) => { + const wt = store.getSnapshot().state.worktrees.list.find((w) => w.path === worktreePath) + return runTool(worktreePath, toolId, { + branch: wt?.branch ?? '', + repoRoot: wt?.repoRoot ?? worktreePath + }) + }) + transport.onRequest('worktree:commitDiff', async (_ctx, worktreePath: string, hash: string) => { return getCommitDiff(worktreePath, hash) }) diff --git a/src/main/repo-config.ts b/src/main/repo-config.ts index c1a49db24..26a95a63e 100644 --- a/src/main/repo-config.ts +++ b/src/main/repo-config.ts @@ -60,7 +60,7 @@ export function saveRepoConfig(repoRoot: string, next: RepoConfig): RepoConfig { if (next.mergeStrategy) cleaned.mergeStrategy = next.mergeStrategy // Migrate legacy hideMergePanel / hidePrPanel into hiddenRightPanels // on write. Only the new field is persisted going forward. - const hidden: Record = { ...(next.hiddenRightPanels || {}) } + const hidden: Record = { ...(next.hiddenRightPanels || {}) } if (next.hideMergePanel && hidden.merge === undefined) hidden.merge = true if (next.hidePrPanel && hidden.pr === undefined) hidden.pr = true // Compact: drop `false` entries that match the default visibility diff --git a/src/main/tools.test.ts b/src/main/tools.test.ts new file mode 100644 index 000000000..7b6ea16e9 --- /dev/null +++ b/src/main/tools.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { discoverTools, runTool } from './tools' + +let root: string + +function addTool(id: string, manifest: unknown, script?: string): string { + const dir = join(root, '.harness/tools', id) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'tool.json'), JSON.stringify(manifest)) + if (script !== undefined) { + const scriptPath = join(dir, 'run.sh') + writeFileSync(scriptPath, script) + chmodSync(scriptPath, 0o755) + } + return dir +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'harness-tools-')) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +const ctx = { branch: 'feature/x', repoRoot: '/repo' } + +describe('discoverTools', () => { + it('returns [] when there is no tools directory', () => { + expect(discoverTools(root)).toEqual([]) + }) + + it('reads the manifest and defaults script + refresh', () => { + addTool('pr-comments', { title: 'PR Comments' }) + const [spec] = discoverTools(root) + expect(spec.id).toBe('pr-comments') + expect(spec.title).toBe('PR Comments') + expect(spec.script).toBe('run.sh') + expect(spec.refresh).toBe('manual') + }) + + it('falls back to the directory name when title is missing', () => { + addTool('deploys', {}) + expect(discoverTools(root)[0].title).toBe('deploys') + }) + + it('skips a directory whose manifest is malformed', () => { + const dir = join(root, '.harness/tools/broken') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'tool.json'), '{not json') + expect(discoverTools(root)).toEqual([]) + }) + + it('rejects a script path that escapes the tool directory', () => { + addTool('evil', { title: 'Evil', script: '../../../../bin/sh' }) + expect(discoverTools(root)).toEqual([]) + }) +}) + +describe('runTool', () => { + it('returns stdout as markdown', async () => { + addTool('hello', { title: 'Hello' }, '#!/bin/sh\necho "## Section"\necho "- a row"\n') + const res = await runTool(root, 'hello', ctx) + expect(res.ok).toBe(true) + expect(res.markdown).toContain('## Section') + expect(res.markdown).toContain('- a row') + }) + + it('exposes the harness env vars to the script', async () => { + addTool('env', { title: 'Env' }, '#!/bin/sh\necho "$HARNESS_BRANCH $HARNESS_TOOL_ID"\n') + const res = await runTool(root, 'env', ctx) + expect(res.markdown.trim()).toBe('feature/x env') + }) + + it('reports a non-zero exit but still surfaces any output', async () => { + addTool('fails', { title: 'Fails' }, '#!/bin/sh\necho "partial"\necho "boom" >&2\nexit 3\n') + const res = await runTool(root, 'fails', ctx) + expect(res.ok).toBe(false) + expect(res.markdown).toContain('partial') + expect(res.error).toContain('boom') + }) + + it('errors on an unknown tool id', async () => { + const res = await runTool(root, 'nope', ctx) + expect(res.ok).toBe(false) + expect(res.error).toContain('Unknown tool') + }) + + it('errors when the manifest points at a missing script', async () => { + addTool('noscript', { title: 'No Script' }) + const res = await runTool(root, 'noscript', ctx) + expect(res.ok).toBe(false) + expect(res.error).toContain('Script not found') + }) + + it('tells the user to chmod +x when the script is not executable', async () => { + addTool('noexec', { title: 'No Exec' }, '#!/bin/sh\necho hi\n') + chmodSync(join(root, '.harness/tools/noexec/run.sh'), 0o644) + const res = await runTool(root, 'noexec', ctx) + expect(res.ok).toBe(false) + expect(res.error).toContain('chmod +x') + }) +}) diff --git a/src/main/tools.ts b/src/main/tools.ts new file mode 100644 index 000000000..53a233096 --- /dev/null +++ b/src/main/tools.ts @@ -0,0 +1,161 @@ +import { spawn } from 'child_process' +import { existsSync, readdirSync, readFileSync, statSync } from 'fs' +import { join } from 'path' +import { log } from './debug' +import { + DEFAULT_TOOL_SCRIPT, + TOOL_MANIFEST_FILENAME, + TOOLS_DIRNAME, + type ToolRunResult, + type ToolSpec +} from '../shared/tools' + +const RUN_TIMEOUT_MS = 20_000 +const MAX_OUTPUT_BYTES = 256 * 1024 + + +/** Tools live in the worktree, not the repo root, so a branch can iterate + * on its own tooling and a PR that edits a tool exercises the new version. + * (Note this differs from `.harness.json`, which resolves against repoRoot.) */ +function toolsDir(worktreePath: string): string { + return join(worktreePath, TOOLS_DIRNAME) +} + +function parseManifest(dir: string, id: string): ToolSpec | null { + const manifestPath = join(dir, TOOL_MANIFEST_FILENAME) + if (!existsSync(manifestPath)) return null + try { + const raw = JSON.parse(readFileSync(manifestPath, 'utf-8')) as Record + const title = typeof raw.title === 'string' && raw.title.trim() ? raw.title.trim() : id + const script = + typeof raw.script === 'string' && raw.script.trim() ? raw.script.trim() : DEFAULT_TOOL_SCRIPT + // Keep the script inside its own tool directory — a manifest shouldn't + // be able to point at an arbitrary path elsewhere on disk. + if (script.startsWith('/') || script.split('/').includes('..')) { + log('tools', `tool ${id}: rejecting script path outside tool dir: ${script}`) + return null + } + return { + id, + title, + script, + dir, + refresh: raw.refresh === 'auto' ? 'auto' : 'manual' + } + } catch (err) { + log('tools', `tool ${id}: failed to parse manifest: ${(err as Error).message}`) + return null + } +} + +export function discoverTools(worktreePath: string): ToolSpec[] { + if (!worktreePath) return [] + const root = toolsDir(worktreePath) + if (!existsSync(root)) return [] + let entries: string[] + try { + entries = readdirSync(root) + } catch (err) { + log('tools', `failed to read ${root}: ${(err as Error).message}`) + return [] + } + const specs: ToolSpec[] = [] + for (const id of entries.sort()) { + if (id.startsWith('.')) continue + const dir = join(root, id) + try { + if (!statSync(dir).isDirectory()) continue + } catch { + continue + } + const spec = parseManifest(dir, id) + if (spec) specs.push(spec) + } + return specs +} + +export async function runTool( + worktreePath: string, + toolId: string, + ctx: { branch: string; repoRoot: string } +): Promise { + const spec = discoverTools(worktreePath).find((t) => t.id === toolId) + if (!spec) return { ok: false, markdown: '', error: `Unknown tool: ${toolId}` } + const scriptPath = join(spec.dir, spec.script) + if (!existsSync(scriptPath)) { + return { ok: false, markdown: '', error: `Script not found: ${spec.script}` } + } + + return new Promise((resolve) => { + let stdout = '' + let stderr = '' + let settled = false + const finish = (result: ToolRunResult): void => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(result) + } + + let child: ReturnType | null = null + const timer = setTimeout(() => { + child?.kill('SIGKILL') + finish({ ok: false, markdown: stdout, error: `Timed out after ${RUN_TIMEOUT_MS / 1000}s` }) + }, RUN_TIMEOUT_MS) + + try { + // Spawned directly rather than through a login shell: the script's + // own shebang decides the interpreter, and rc-file chatter (nvm + // banners, starship init) can't leak into stdout — which here IS + // the panel body. PATH is already the login-shell PATH thanks to + // path-fix.ts at boot, so there's nothing to gain from `-ilc`. + child = spawn(scriptPath, [], { + cwd: worktreePath, + env: { + ...process.env, + HARNESS_WORKTREE_PATH: worktreePath, + HARNESS_BRANCH: ctx.branch, + HARNESS_REPO_ROOT: ctx.repoRoot, + HARNESS_TOOL_DIR: spec.dir, + HARNESS_TOOL_ID: spec.id + } + }) + } catch (err) { + finish({ ok: false, markdown: '', error: (err as Error).message }) + return + } + + child.stdout?.on('data', (d) => { + if (stdout.length < MAX_OUTPUT_BYTES) stdout += d.toString() + }) + child.stderr?.on('data', (d) => { + if (stderr.length < MAX_OUTPUT_BYTES) stderr += d.toString() + }) + child.on('error', (err) => { + const code = (err as NodeJS.ErrnoException).code + finish({ + ok: false, + markdown: '', + error: + code === 'EACCES' + ? `${spec.script} is not executable — run chmod +x` + : err.message + }) + }) + child.on('close', (code) => { + const truncated = stdout.length >= MAX_OUTPUT_BYTES + const markdown = truncated ? stdout.slice(0, MAX_OUTPUT_BYTES) : stdout + if (code === 0) { + finish({ ok: true, markdown }) + return + } + // A failing script that still printed something gets to render its + // own output — it may be formatting the error better than we can. + finish({ + ok: false, + markdown, + error: stderr.trim().slice(0, 500) || `Exited with code ${code}` + }) + }) + }) +} diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index f1344d442..9e0e62696 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -205,6 +205,8 @@ export function buildBackend( mode?: 'working' | 'branch' ) => req('worktree:fileDiffSides', worktreePath, filePath, staged, mode), getBranchCommits: (worktreePath: string) => req('worktree:branchCommits', worktreePath), + listTools: (worktreePath: string) => req('tools:list', worktreePath), + runTool: (worktreePath: string, toolId: string) => req('tools:run', worktreePath, toolId), getCommitDiff: (worktreePath: string, hash: string) => req('worktree:commitDiff', worktreePath, hash), getCommitMeta: (worktreePath: string, hash: string) => diff --git a/src/renderer/components/CustomToolPanel.tsx b/src/renderer/components/CustomToolPanel.tsx new file mode 100644 index 000000000..c97dd9f50 --- /dev/null +++ b/src/renderer/components/CustomToolPanel.tsx @@ -0,0 +1,99 @@ +import { useCallback } from 'react' +import { RefreshCw, TriangleAlert } from 'lucide-react' +import type { ToolRunResult, ToolSpec } from '../types' +import { Tooltip } from './Tooltip' +import { RightPanel } from './RightPanel' +import { SidebarMarkdown, type HarnessLinkAction } from './SidebarMarkdown' +import { useWatchedQuery } from '../hooks/useWatchedQuery' +import { toolPanelKey } from '../../shared/state/repo-configs' +import { useBackend } from '../backend' + +interface CustomToolPanelProps { + spec: ToolSpec + worktreePath: string | null + onSendToAgent?: (text: string) => void + onOpenFile?: (filePath: string) => void +} + +export function CustomToolPanel({ + spec, + worktreePath, + onSendToAgent, + onOpenFile +}: CustomToolPanelProps): JSX.Element | null { + const backend = useBackend() + const fetcher = useCallback( + (path: string) => backend.runTool(path, spec.id), + [backend, spec.id] + ) + + const { data, loading, refresh } = useWatchedQuery({ + worktreePath, + cacheKey: `tool:${spec.id}`, + fetcher, + // A `manual` tool still runs on mount and on the refresh button, but + // never on a timer or a git change — tool scripts routinely hit the + // network, and the built-in panels' cadence would hammer an API. + fallbackPollMs: spec.refresh === 'auto' ? 30000 : 0, + revalidateOnFileChange: spec.refresh === 'auto' + }) + + const handleAction = useCallback( + (action: HarnessLinkAction) => { + if (action.verb === 'send') { + const text = action.params.get('text') + if (text) onSendToAgent?.(text) + return + } + if (action.verb === 'file') { + const path = action.params.get('path') + if (path) onOpenFile?.(path) + return + } + if (action.verb === 'refresh') refresh() + }, + [onSendToAgent, onOpenFile, refresh] + ) + + if (!worktreePath) return null + + const actions = ( + <> + {data && !data.ok && ( + + + + )} + + + + + ) + + const body = data?.markdown?.trim() + + return ( + +
+ {!data && loading &&
Running…
} + {body ? ( + + ) : ( + data && ( +
+ {data.ok ? 'No output' : data.error || 'Tool failed'} +
+ ) + )} +
+
+ ) +} diff --git a/src/renderer/components/RightColumn.tsx b/src/renderer/components/RightColumn.tsx index 92579add1..1adba9fcf 100644 --- a/src/renderer/components/RightColumn.tsx +++ b/src/renderer/components/RightColumn.tsx @@ -1,10 +1,15 @@ -import type { PRStatus, Worktree, RepoConfig } from '../types' +import { useCallback, useMemo } from 'react' +import type { PRStatus, Worktree, RepoConfig, ToolSpec } from '../types' import { effectiveHiddenRightPanels, effectiveRightPanelOrder, + isCustomToolPanelKey, + toolPanelKey, type HiddenRightPanels, type RightPanelKey } from '../../shared/state/repo-configs' +import { useWatchedQuery } from '../hooks/useWatchedQuery' +import { CustomToolPanel } from './CustomToolPanel' import { PRStatusPanel, MergeLocallyPanel } from './PRStatusPanel' import { BranchCommitsPanel } from './BranchCommitsPanel' import { ChangedFilesPanel } from './ChangedFilesPanel' @@ -66,8 +71,25 @@ export function RightColumn({ onCollapse }: RightColumnProps): JSX.Element { const backend = useBackend() + + const toolsFetcher = useCallback( + (path: string) => backend.listTools(path), + [backend] + ) + const { data: toolsData } = useWatchedQuery({ + worktreePath: activeWorktreeId, + cacheKey: 'tools', + fetcher: toolsFetcher + }) + const tools = useMemo(() => toolsData ?? [], [toolsData]) + const toolKeys = useMemo(() => tools.map((t) => toolPanelKey(t.id)), [tools]) + const toolLabels = useMemo( + () => Object.fromEntries(tools.map((t) => [toolPanelKey(t.id), t.title])), + [tools] + ) + const hidden = effectiveHiddenRightPanels(activeRepoConfig) - const order = effectiveRightPanelOrder(activeRepoConfig) + const order = effectiveRightPanelOrder(activeRepoConfig, toolKeys) const handleChangeHidden = (next: HiddenRightPanels): void => { if (!activeRepoRoot) return @@ -89,6 +111,21 @@ export function RightColumn({ const renderPanel = (key: RightPanelKey): JSX.Element | null => { if (hidden[key]) return null + if (isCustomToolPanelKey(key)) { + const spec = tools.find((t) => toolPanelKey(t.id) === key) + if (!spec) return null + return ( + onSendToAgent(activeWorktreeId, text) : undefined + } + onOpenFile={onOpenFile} + /> + ) + } switch (key) { case 'merge': return ( @@ -159,6 +196,7 @@ export function RightColumn({