diff --git a/CLAUDE.md b/CLAUDE.md index 0415d11c..95cd8e35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,7 +95,7 @@ src/ │ ├── worktrees.ts # list, repoRoots, pending FSM entries │ ├── terminals.ts # statuses, pendingTools, shellActivity, panes, lastActive │ ├── onboarding.ts # quest step -│ ├── hooks.ts # consent + justInstalled +│ ├── hooks.ts # Codex hooks consent (Claude rides on --plugin-dir) │ ├── updater.ts # status (checking/available/downloading/…) │ ├── repo-configs.ts # byRepo: per-repo .harness.json contents │ └── *.test.ts # vitest reducer tests, one per slice @@ -195,10 +195,6 @@ Some main-side modules subscribe to the store and react to events: `terminals/*` and `prs/*` events, computes per-worktree effective state, debounces `lastActive` updates, dedups `recordActivity` calls to `activity.ts`. -- **`installHooksForAcceptedWorktrees`** — small subscriber in - `main/index.ts` that listens for `worktrees/listChanged` and - `hooks/consentChanged`, installs hooks into any new worktree if - consent is `'accepted'`. Construction order in `main/index.ts` matters: `PanesFSM` is constructed **before** `WorktreesFSM` because the latter's `onWorktreeCreated` @@ -288,11 +284,42 @@ event type if you're trying to find where something happens. ## How status detection works The reliable status (processing / waiting / needs-approval) comes from -**Claude Code hooks** that we install into each worktree's -`.claude/settings.local.json`. The hooks write a status JSON to -`/tmp/harness-status/.json` and the main process watches that -directory via `fs.watch`. The hook script uses `$CLAUDE_HARNESS_ID` env var -which the PtyManager sets when spawning each terminal. +**Claude Code hooks** that emit one NDJSON line per event to +`/tmp/harness-status/.ndjson`. The main process watches that +directory via `fs.watch` and tails the file (see `src/main/hooks.ts`). +The hook command env-gates on `$HARNESS_TERMINAL_ID` (set by PtyManager +when it spawns each tab), so sessions outside Harness no-op cleanly. + +**How the hooks get loaded:** + +- **Claude** — Harness ships a local Claude Code plugin under + `resources/plugins/harness-status/` (manifest at + `.claude-plugin/plugin.json`, hooks at `hooks/hooks.json`). Every + Claude spawn — both the xterm path and json-mode — passes + `--plugin-dir `. Nothing is written to + `~/.claude/settings.json`; no consent is required. The plugin is + resolved via `harnessPluginDir()` in `src/main/claude-plugin.ts` + (mirrors the `process.resourcesPath` pattern used by `mcp-bridge.js`). + A drift-detection test in `src/main/claude-plugin.test.ts` asserts the + shipped `hooks.json` matches what `makeHookCommand()` would generate + today so the two can't silently diverge. +- **Codex** — Codex 0.133+ reads the **same** plugin tree as Claude. + No `--plugin-dir` flag exists for Codex, so we register the bundled + directory as a local marketplace via `codex plugin marketplace add + ` and enable the plugin with `codex plugin add + harness-status@harness`. Both commands write entries to + `~/.codex/config.toml`, which is why this path is still gated by the + consent banner / Settings card. The `hooks/consent` slice + + `hooks:accept|decline|uninstall` IPC handlers wire the user choice; + see `src/main/codex-plugin.ts` for the install / uninstall helpers. + Two one-shot boot migrations sweep dead state from prior installs: + `config.hooksMigratedToPlugin` strips legacy Claude entries from + `~/.claude/settings.json` + per-worktree `.claude/settings.local.json`; + `config.codexPluginMigrated` strips legacy Harness entries from + `~/.codex/hooks.json` + per-worktree `.codex/hooks.json`. + After acceptance, `installCodexPlugin()` runs on every boot — + idempotent, and the only way to force Codex's plugin cache to pick up + a new Harness release (the cache never refreshes automatically). ## How performance debugging works diff --git a/package.json b/package.json index d455df8d..5641813a 100644 --- a/package.json +++ b/package.json @@ -63,13 +63,13 @@ "**/node_modules/@anthropic-ai/claude-code-*/**/*" ], "extraResources": [ - { - "from": "resources/mcp-bridge.js", - "to": "mcp-bridge.js" - }, { "from": "resources/permission-prompt-mcp.js", "to": "permission-prompt-mcp.js" + }, + { + "from": "resources/plugins", + "to": "plugins" } ], "mac": { diff --git a/resources/mcp-bridge.test.js b/resources/mcp-bridge.test.js index 4e669e1a..121336dd 100644 --- a/resources/mcp-bridge.test.js +++ b/resources/mcp-bridge.test.js @@ -6,7 +6,7 @@ import { dirname, join } from 'path' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -const BRIDGE = join(__dirname, 'mcp-bridge.js') +const BRIDGE = join(__dirname, 'plugins', 'harness-status', 'servers', 'mcp-bridge.js') function startStub(handler) { return new Promise((resolve) => { diff --git a/resources/plugins/.agents/plugins/marketplace.json b/resources/plugins/.agents/plugins/marketplace.json new file mode 100644 index 00000000..2840849d --- /dev/null +++ b/resources/plugins/.agents/plugins/marketplace.json @@ -0,0 +1,10 @@ +{ + "name": "harness", + "version": "1.0.0", + "plugins": [ + { + "name": "harness-status", + "source": "./harness-status" + } + ] +} diff --git a/resources/plugins/harness-status/.claude-plugin/plugin.json b/resources/plugins/harness-status/.claude-plugin/plugin.json new file mode 100644 index 00000000..7f424d21 --- /dev/null +++ b/resources/plugins/harness-status/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "harness-status", + "version": "1.0.0", + "description": "Integrate with Harness for up-to-date status" +} diff --git a/resources/plugins/harness-status/.mcp.json b/resources/plugins/harness-status/.mcp.json new file mode 100644 index 00000000..c5dde0b8 --- /dev/null +++ b/resources/plugins/harness-status/.mcp.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "harness-control": { + "command": "${HARNESS_NODE_EXEC}", + "args": ["${CLAUDE_PLUGIN_ROOT}/servers/mcp-bridge.js"], + "env": { + "ELECTRON_RUN_AS_NODE": "1", + "HARNESS_PORT": "${HARNESS_PORT}", + "HARNESS_TOKEN": "${HARNESS_TOKEN:-}", + "HARNESS_TERMINAL_ID": "${HARNESS_MCP_TERMINAL_ID}", + "HARNESS_SESSION_ID": "${HARNESS_MCP_TERMINAL_ID}", + "HARNESS_WORKTREE_ID": "${HARNESS_WORKTREE_ID:-}", + "HARNESS_REPO_ROOT": "${HARNESS_REPO_ROOT:-}", + "HARNESS_IS_MAIN": "${HARNESS_IS_MAIN:-}" + } + } + } +} diff --git a/resources/plugins/harness-status/hooks/hooks.json b/resources/plugins/harness-status/hooks/hooks.json new file mode 100644 index 00000000..4f33c462 --- /dev/null +++ b/resources/plugins/harness-status/hooks/hooks.json @@ -0,0 +1,70 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -c 'h=\"$HARNESS_TERMINAL_ID\"; [ -z \"$h\" ] && h=\"$CLAUDE_HARNESS_ID\"; [ -z \"$h\" ] && exit 0; d=/tmp/harness-status; mkdir -p \"$d\"; p=$(cat); [ -z \"$p\" ] && p=null; printf \"{\\\"event\\\":\\\"SessionStart\\\",\\\"ts\\\":%s,\\\"payload\\\":%s}\\n\" \"$(date +%s)\" \"$p\" >> \"$d/$h.ndjson\"'", + "timeout": 5 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -c 'h=\"$HARNESS_TERMINAL_ID\"; [ -z \"$h\" ] && h=\"$CLAUDE_HARNESS_ID\"; [ -z \"$h\" ] && exit 0; d=/tmp/harness-status; mkdir -p \"$d\"; p=$(cat); [ -z \"$p\" ] && p=null; printf \"{\\\"event\\\":\\\"UserPromptSubmit\\\",\\\"ts\\\":%s,\\\"payload\\\":%s}\\n\" \"$(date +%s)\" \"$p\" >> \"$d/$h.ndjson\"'", + "timeout": 5 + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -c 'h=\"$HARNESS_TERMINAL_ID\"; [ -z \"$h\" ] && h=\"$CLAUDE_HARNESS_ID\"; [ -z \"$h\" ] && exit 0; d=/tmp/harness-status; mkdir -p \"$d\"; p=$(cat); [ -z \"$p\" ] && p=null; printf \"{\\\"event\\\":\\\"PreToolUse\\\",\\\"ts\\\":%s,\\\"payload\\\":%s}\\n\" \"$(date +%s)\" \"$p\" >> \"$d/$h.ndjson\"'", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -c 'h=\"$HARNESS_TERMINAL_ID\"; [ -z \"$h\" ] && h=\"$CLAUDE_HARNESS_ID\"; [ -z \"$h\" ] && exit 0; d=/tmp/harness-status; mkdir -p \"$d\"; p=$(cat); [ -z \"$p\" ] && p=null; printf \"{\\\"event\\\":\\\"PostToolUse\\\",\\\"ts\\\":%s,\\\"payload\\\":%s}\\n\" \"$(date +%s)\" \"$p\" >> \"$d/$h.ndjson\"'", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -c 'h=\"$HARNESS_TERMINAL_ID\"; [ -z \"$h\" ] && h=\"$CLAUDE_HARNESS_ID\"; [ -z \"$h\" ] && exit 0; d=/tmp/harness-status; mkdir -p \"$d\"; p=$(cat); [ -z \"$p\" ] && p=null; printf \"{\\\"event\\\":\\\"Stop\\\",\\\"ts\\\":%s,\\\"payload\\\":%s}\\n\" \"$(date +%s)\" \"$p\" >> \"$d/$h.ndjson\"'", + "timeout": 5 + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "bash -c 'h=\"$HARNESS_TERMINAL_ID\"; [ -z \"$h\" ] && h=\"$CLAUDE_HARNESS_ID\"; [ -z \"$h\" ] && exit 0; d=/tmp/harness-status; mkdir -p \"$d\"; p=$(cat); [ -z \"$p\" ] && p=null; printf \"{\\\"event\\\":\\\"Notification\\\",\\\"ts\\\":%s,\\\"payload\\\":%s}\\n\" \"$(date +%s)\" \"$p\" >> \"$d/$h.ndjson\"'", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/resources/mcp-bridge.js b/resources/plugins/harness-status/servers/mcp-bridge.js similarity index 100% rename from resources/mcp-bridge.js rename to resources/plugins/harness-status/servers/mcp-bridge.js diff --git a/resources/plugins/harness-status/skills/harness-browser/SKILL.md b/resources/plugins/harness-status/skills/harness-browser/SKILL.md new file mode 100644 index 00000000..26025fbb --- /dev/null +++ b/resources/plugins/harness-status/skills/harness-browser/SKILL.md @@ -0,0 +1,36 @@ +--- +name: harness-browser +description: Drive Harness's embedded browser tabs to verify UI changes, debug a rendered page, click through a flow, or inspect a dev server. Use when the user mentions a browser, a URL, the rendered UI, clicking/typing in a page, taking a screenshot, or verifying a web change works. +--- + +# Browser tabs in Harness + +Harness embeds browser tabs alongside your terminal. They're scoped to the current worktree and driven by the `harness-control` MCP tools. Prefer these over blind `curl`/`fetch` or shelling out to `open ` — `curl` can't render JS or inspect DOM state, and `open` launches the user's default browser outside Harness where you can't see what happened. + +## Click targeting workflow + +**Prefer `get_tab_clickables` → match by role + name → call `click_tab(cx, cy)`** for anything you want to click. It's far cheaper than a screenshot + vision pass and far more reliable for real DOM targets. + +The clickables snapshot is: +- In-viewport only — if the target isn't there, `scroll_tab` first then re-snapshot +- Capped at 500 items +- Includes elements inside open shadow roots +- Returns `{role, name, cx, cy, w, h}` with the click center already computed + +Reserve `screenshot_tab` for: +- Confirming a click had the visual effect you wanted +- Targets without accessible names (canvas/SVG/images) where clickables can't help + +Default screenshot format is JPEG quality 70 (context-efficient). Ask for `format: 'png'` only when lossless matters. Screenshot dimensions match the CSS viewport, so any coords you read off a screenshot can be passed straight to `click_tab`. + +## Typing into fields + +`click_tab` on the field first to focus it, then `type_tab`. `type_tab` also accepts a `key` arg (`Enter`, `Tab`, `Backspace`, `ArrowDown`, …) for submitting forms or navigating menus. + +## Tools at a glance + +- `create_browser_tab` — open a new tab in this worktree (optionally navigating to a URL) +- `list_browser_tabs`, `get_tab_url`, `get_tab_dom`, `get_tab_console_logs` — inspect +- `navigate_tab`, `back_tab`, `forward_tab`, `reload_tab` — drive +- `get_tab_clickables`, `click_tab`, `type_tab`, `scroll_tab`, `show_cursor` — interact +- `screenshot_tab` — visual verification diff --git a/resources/plugins/harness-status/skills/harness-shell/SKILL.md b/resources/plugins/harness-status/skills/harness-shell/SKILL.md new file mode 100644 index 00000000..dd8acd8f --- /dev/null +++ b/resources/plugins/harness-status/skills/harness-shell/SKILL.md @@ -0,0 +1,28 @@ +--- +name: harness-shell +description: Use Harness shell tabs (create_shell, read_shell_output, kill_shell) instead of Bash for long-running processes — dev servers, watchers, `tail -f`, REPL-style tools, long builds. Use when the user asks to start a dev server, tail logs, run a watcher, or anything that wouldn't naturally exit within a few seconds. +--- + +# Shell tabs in Harness + +For processes that wouldn't naturally exit within a few seconds — dev servers, watchers, `tail -f`, REPL-style tools, long builds — use the harness-control shell tools instead of Bash. + +## Why not Bash? + +- Bash either **blocks** until the process exits, or **loses the output stream** when backgrounded. +- Harness shell tabs keep streaming, stay readable via `read_shell_output` after the fact, and are visible to the user in the Harness UI. + +## Tools + +- `create_shell` — spawn a shell tab, optionally with a command (`zsh -ilc `). Returns an id; keep it for later reads. +- `list_shells` — enumerate existing shell tabs. **Check here before spawning** — don't start a second `npm run dev` if one is already running. +- `read_shell_output` — read a shell's output (ANSI stripped). Use `match` + `context` to scan long logs for errors/warnings without pulling back megabytes. +- `kill_shell` — terminate the process AND close the tab. For natural exits the tab stays open for inspection; `kill_shell` is explicit cleanup. + +## Short one-shots stay on Bash + +`npm test`, `tsc --noEmit`, `git status`, `npm install` — anything that exits in a few seconds belongs on Bash. The streaming + visibility benefits of a shell tab aren't worth the tab clutter for those. + +## Reading busy logs efficiently + +`read_shell_output` accepts a `match` regex (case-insensitive) with a `context` line count. For a 10MB build log, pulling the whole thing wastes context — scan for `error|warn|fail` with `context: 3` instead. diff --git a/resources/plugins/harness-status/skills/harness-worktree/SKILL.md b/resources/plugins/harness-status/skills/harness-worktree/SKILL.md new file mode 100644 index 00000000..f2e57b98 --- /dev/null +++ b/resources/plugins/harness-status/skills/harness-worktree/SKILL.md @@ -0,0 +1,31 @@ +--- +name: harness-worktree +description: Spin off an isolated Harness worktree (own git branch, own Claude session) for a new task, fix, investigation, or anything that would benefit from working in parallel. Use when the user describes a task that could be delegated, when work needs isolation from the current branch, or when the user mentions "new worktree", "split this off", "work on this in parallel", or "delegate". +--- + +# Worktrees in Harness + +Each worktree is an independent git branch with its own terminal and Claude session. Suggest spawning one when the user wants to start something that would benefit from isolation, parallelization, or a fresh context. + +## Tools + +- `mcp__harness-control__create_worktree` — create a new worktree with its own Claude session. **Always provide a detailed `initialPrompt`** so the new session has full context; it won't see your conversation. +- `mcp__harness-control__list_worktrees` — list active worktrees. + +## Writing a good `initialPrompt` + +Brief the new session like a smart colleague who just walked into the room — they haven't seen this conversation, don't know what you've tried, don't understand why this task matters. + +- **Explain what you're trying to accomplish and why.** +- Describe what you've already learned or ruled out. +- Give enough context about the surrounding problem that they can make judgment calls, not just follow narrow instructions. +- Include file paths, line numbers, and specifics — proof you understood the task. +- If you need a short response, say so explicitly. + +Terse command-style prompts produce shallow, generic work. + +## When NOT to spin off a worktree + +- Tiny edits, single-file fixes, anything that takes less context to do than to brief. +- Tasks that genuinely need the current conversation's running state (open browser tabs, terminal output you'd lose, in-progress reasoning). +- The user said "do it here." Don't second-guess explicit scope. diff --git a/src/main/agents/claude.test.ts b/src/main/agents/claude.test.ts index 5cb6e24c..7b786140 100644 --- a/src/main/agents/claude.test.ts +++ b/src/main/agents/claude.test.ts @@ -29,7 +29,7 @@ vi.mock('../hooks', () => ({ import { homedir } from 'os' import { join } from 'path' -import { buildSpawnArgs, hooksInstalled, installHooks, hookEvents, uninstallHooks } from './claude' +import { buildSpawnArgs, hookEvents, stripGlobalHooks } from './claude' const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json') @@ -62,63 +62,42 @@ describe('buildSpawnArgs', () => { expect(result).toContain('--append-system-prompt') expect(result).toContain("'\\''") }) -}) -describe('hook install / dedup', () => { - it('hooksInstalled() recognizes normalized entries with no _marker field', () => { - // Simulate what Claude Code leaves behind after normalizing settings.json: - // the _marker and _version sidecar fields are stripped, only the - // {type, command, timeout} triple remains. - const settings = { - hooks: { - UserPromptSubmit: [ - { - hooks: [ - { - type: 'command', - command: - "bash -c 'd=/tmp/harness-status; printf hi >> \"$d/$h.ndjson\"'", - timeout: 5 - } - ] - } - ] - } - } - fsState.files.set(SETTINGS_PATH, JSON.stringify(settings)) - expect(hooksInstalled()).toBe(true) + it('passes --plugin-dir pointing at the bundled Harness status plugin', () => { + const result = buildSpawnArgs({ ...base }) + expect(result).toContain('--plugin-dir') + expect(result).toContain('resources/plugins/harness-status') }) +}) - it('hooksInstalled() returns false when only user-authored hooks exist', () => { - const settings = { - hooks: { - UserPromptSubmit: [ - { - hooks: [{ type: 'command', command: 'echo user hook', timeout: 5 }] - } - ] - } - } - fsState.files.set(SETTINGS_PATH, JSON.stringify(settings)) - expect(hooksInstalled()).toBe(false) +describe('stripGlobalHooks (legacy migration)', () => { + it('returns false when settings.json has no Harness entries', () => { + fsState.files.set( + SETTINGS_PATH, + JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { + hooks: [{ type: 'command', command: 'echo user hook', timeout: 5 }] + } + ] + } + }) + ) + expect(stripGlobalHooks()).toBe(false) + const after = JSON.parse(fsState.files.get(SETTINGS_PATH) as string) + expect(after.hooks.UserPromptSubmit).toHaveLength(1) }) - it('installHooks() called twice yields exactly one harness entry per event', () => { - installHooks() - installHooks() - const settings = JSON.parse(fsState.files.get(SETTINGS_PATH) as string) - for (const event of hookEvents) { - const entries = settings.hooks[event] - expect(entries).toHaveLength(1) - expect(entries[0].hooks[0].command).toContain('/tmp/harness-status') - } + it('returns false when settings.json does not exist', () => { + expect(stripGlobalHooks()).toBe(false) }) - it('installHooks() collapses pre-existing duplicates left by buggy passes', () => { - // Three duplicate harness entries per event, all in normalized form - // (no _marker / _version). This is the exact shape the user reports - // after several buggy install passes. - const dupEntry = { + it('removes legacy Harness entries while preserving user-authored hooks', () => { + const userHook = { + hooks: [{ type: 'command', command: 'echo user hook', timeout: 10 }] + } + const harnessHook = { hooks: [ { type: 'command', @@ -128,66 +107,63 @@ describe('hook install / dedup', () => { } ] } - const settings: { hooks: Record } = { hooks: {} } - for (const event of hookEvents) { - settings.hooks[event] = [dupEntry, dupEntry, dupEntry] - } - fsState.files.set(SETTINGS_PATH, JSON.stringify(settings)) - - installHooks() - - const after = JSON.parse(fsState.files.get(SETTINGS_PATH) as string) - for (const event of hookEvents) { - expect(after.hooks[event]).toHaveLength(1) - } - }) - - it('installHooks() preserves user-authored hooks (commands not pointing at /tmp/harness-status)', () => { - const userHook = { - hooks: [{ type: 'command', command: 'echo user hook', timeout: 10 }] - } fsState.files.set( SETTINGS_PATH, JSON.stringify({ hooks: { - UserPromptSubmit: [userHook], - PreToolUse: [userHook] + UserPromptSubmit: [userHook, harnessHook], + PreToolUse: [harnessHook] }, unrelatedKey: 'preserve-me' }) ) - installHooks() - + expect(stripGlobalHooks()).toBe(true) const after = JSON.parse(fsState.files.get(SETTINGS_PATH) as string) expect(after.unrelatedKey).toBe('preserve-me') - // User hook still there + one harness entry appended - expect(after.hooks.UserPromptSubmit).toContainEqual(userHook) - expect(after.hooks.PreToolUse).toContainEqual(userHook) - for (const event of hookEvents) { - const harnessEntries = (after.hooks[event] as Array<{ hooks: { command: string }[] }>).filter( - (e) => e.hooks.some((h) => h.command.includes('/tmp/harness-status')) - ) - expect(harnessEntries).toHaveLength(1) - } + // User hook survives; harness entry stripped. + expect(after.hooks.UserPromptSubmit).toEqual([userHook]) + // Event with only harness entry → key removed entirely. + expect(after.hooks.PreToolUse).toBeUndefined() }) - it('uninstallHooks() removes harness entries but preserves user-authored hooks', () => { - installHooks() - // Add a user-authored hook alongside - const after = JSON.parse(fsState.files.get(SETTINGS_PATH) as string) - after.hooks.UserPromptSubmit.push({ - hooks: [{ type: 'command', command: 'echo user hook' }] - }) - fsState.files.set(SETTINGS_PATH, JSON.stringify(after)) + it('drops the hooks object entirely when no events remain', () => { + fsState.files.set( + SETTINGS_PATH, + JSON.stringify({ + hooks: { + PreToolUse: [ + { + hooks: [ + { + type: 'command', + command: 'bash -c \'d=/tmp/harness-status; echo x\'', + timeout: 5 + } + ] + } + ] + }, + otherKey: 'keep' + }) + ) - uninstallHooks() + expect(stripGlobalHooks()).toBe(true) + const after = JSON.parse(fsState.files.get(SETTINGS_PATH) as string) + expect(after.hooks).toBeUndefined() + expect(after.otherKey).toBe('keep') + }) +}) - const final = JSON.parse(fsState.files.get(SETTINGS_PATH) as string) - expect(final.hooks?.UserPromptSubmit).toEqual([ - { hooks: [{ type: 'command', command: 'echo user hook' }] } +describe('hookEvents', () => { + it('exports the events the bundled plugin must register', () => { + expect(hookEvents).toEqual([ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'Stop', + 'Notification' ]) - // Other events had no user hooks, so they should be gone entirely. - expect(final.hooks?.PreToolUse).toBeUndefined() }) }) diff --git a/src/main/agents/claude.ts b/src/main/agents/claude.ts index dfb2066b..18ced50a 100644 --- a/src/main/agents/claude.ts +++ b/src/main/agents/claude.ts @@ -2,7 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSy import { join } from 'path' import { homedir } from 'os' import { log } from '../debug' -import { makeHookCommand } from '../hooks' +import { harnessPluginDir } from '../claude-plugin' import { shellQuote } from '../shell-quote' import type { AgentSpawnOpts } from './index' @@ -15,6 +15,7 @@ export const defaultCommand = 'claude' export const assignsSessionId = true export const hookEvents = [ + 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', @@ -54,12 +55,6 @@ function writeSettings(path: string, settings: SettingsFile): void { writeFileSync(path, JSON.stringify(settings, null, 2)) } -function makeHarnessHookEntry(command: string): HookEntry { - return { - hooks: [{ type: 'command', command, timeout: 5 }] - } -} - function isHarnessHookEntry(entry: HookEntry): boolean { return !!entry.hooks?.some( (h) => typeof h.command === 'string' && h.command.includes(HARNESS_HOOK_COMMAND_SIGNATURE) @@ -70,50 +65,27 @@ function removeOldHarnessEntries(entries: HookEntry[]): HookEntry[] { return entries.filter((entry) => !isHarnessHookEntry(entry)) } -export function hooksInstalled(): boolean { - const settings = readSettings(globalSettingsPath()) - const hooks = settings.hooks - if (!hooks) return false - for (const entries of Object.values(hooks)) { - for (const entry of entries) { - if (isHarnessHookEntry(entry)) return true - } - } - return false -} - -export function installHooks(): void { +/** One-shot migration: strip any legacy Harness entries from + * ~/.claude/settings.json. Harness used to install hooks there; we now + * ship them as a plugin loaded via --plugin-dir, so the user-scope copy + * is dead weight. Returns true if the file was modified. */ +export function stripGlobalHooks(): boolean { const path = globalSettingsPath() - log('hooks', `installing Claude hooks into ${path}`) - const settings = readSettings(path) - if (!settings.hooks) settings.hooks = {} - - for (const event of Object.keys(settings.hooks)) { - settings.hooks[event] = removeOldHarnessEntries(settings.hooks[event]) - } - - for (const event of hookEvents) { - if (!settings.hooks[event]) settings.hooks[event] = [] - settings.hooks[event].push(makeHarnessHookEntry(makeHookCommand(event))) - } - - writeSettings(path, settings) -} - -/** Remove our entries from ~/.claude/settings.json but leave any user-authored - * hooks + unrelated keys intact. No-op if we're not installed. */ -export function uninstallHooks(): void { - const path = globalSettingsPath() - if (!existsSync(path)) return + if (!existsSync(path)) return false const settings = readSettings(path) - if (!settings.hooks) return + if (!settings.hooks) return false + let changed = false for (const event of Object.keys(settings.hooks)) { + const before = settings.hooks[event].length settings.hooks[event] = removeOldHarnessEntries(settings.hooks[event]) + if (settings.hooks[event].length !== before) changed = true if (settings.hooks[event].length === 0) delete settings.hooks[event] } + if (!changed) return false if (Object.keys(settings.hooks).length === 0) delete settings.hooks writeSettings(path, settings) - log('hooks', `uninstalled Claude hooks from ${path}`) + log('hooks', `stripped legacy Harness entries from ${path}`) + return true } /** Strip any legacy Harness entries from a worktree's .claude/settings.local.json. @@ -168,12 +140,18 @@ export function latestSessionId(cwd: string): string | null { } export function buildSpawnArgs(opts: AgentSpawnOpts): string { + // No --mcp-config flag: the bundled Harness plugin ships its own + // .mcp.json defining the harness-control bridge, loaded via the + // --plugin-dir flag below. The bridge's per-session env (port, + // token, terminal id, scope) is injected onto the PTY's env in + // pty:create (src/main/index.ts) so the plugin's ${...} placeholders + // resolve at Claude launch time. const modelFlag = opts.model && !opts.command.includes('--model') ? ` --model ${shellQuote(opts.model)}` : '' - const mcpFlag = opts.mcpConfigPath ? ` --mcp-config ${shellQuote(opts.mcpConfigPath)}` : '' const nameFlag = opts.sessionName ? ` --name ${shellQuote(opts.sessionName)}` : '' const systemPromptFlag = opts.systemPrompt ? ` --append-system-prompt ${shellQuote(opts.systemPrompt)}` : '' + const pluginFlag = ` --plugin-dir ${shellQuote(harnessPluginDir())}` const tuiPrefix = opts.tuiFullscreen ? 'CLAUDE_CODE_NO_FLICKER=1 ' : '' - const cmd = `${tuiPrefix}${opts.command}${modelFlag}${mcpFlag}${nameFlag}${systemPromptFlag}` + const cmd = `${tuiPrefix}${opts.command}${modelFlag}${nameFlag}${systemPromptFlag}${pluginFlag}` if (opts.teleportSessionId && opts.sessionId) { const exists = sessionFileExists(opts.cwd, opts.sessionId) diff --git a/src/main/agents/codex-spawn.test.ts b/src/main/agents/codex-spawn.test.ts new file mode 100644 index 00000000..d4829328 --- /dev/null +++ b/src/main/agents/codex-spawn.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { buildSpawnArgs } from './codex' + +describe('codex buildSpawnArgs with harnessControl', () => { + it('emits -c mcp_servers.harness-control.* with literal values', () => { + const cmd = buildSpawnArgs({ + command: 'codex', + cwd: '/wt', + harnessControl: { + execPath: '/abs/Electron', + bridgePath: '/abs/bridge.js', + port: 9999, + token: 'secret', + terminalId: 'term-1', + workspaceId: '/wt', + repoRoot: '/repo', + isMain: true + } + }) + console.log('SPAWN:', cmd) + expect(cmd).toContain('-c') + expect(cmd).toContain('mcp_servers.harness-control.command') + expect(cmd).toContain('"/abs/Electron"') + expect(cmd).toContain('"/abs/bridge.js"') + expect(cmd).toContain('HARNESS_PORT="9999"') + expect(cmd).toContain('HARNESS_TOKEN="secret"') + expect(cmd).toContain('HARNESS_TERMINAL_ID="term-1"') + expect(cmd).toContain('HARNESS_IS_MAIN="1"') + }) +}) diff --git a/src/main/agents/codex.test.ts b/src/main/agents/codex.test.ts deleted file mode 100644 index 06e3c486..00000000 --- a/src/main/agents/codex.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const fsState: { files: Map } = { files: new Map() } - -vi.mock('fs', () => ({ - existsSync: (p: string) => fsState.files.has(p), - readFileSync: (p: string) => { - if (!fsState.files.has(p)) throw new Error(`ENOENT: ${p}`) - return fsState.files.get(p) as string - }, - writeFileSync: (p: string, data: string) => { - fsState.files.set(p, data) - }, - appendFileSync: (p: string, data: string) => { - fsState.files.set(p, (fsState.files.get(p) ?? '') + data) - }, - mkdirSync: () => {}, - readdirSync: () => [], - statSync: () => ({ mtimeMs: 0 }) -})) - -vi.mock('../debug', () => ({ - log: () => {} -})) - -vi.mock('../hooks', () => ({ - makeHookCommand: (event: string) => - `bash -c 'd=/tmp/harness-status; printf "${event}" >> "$d/$h.ndjson"'` -})) - -import { homedir } from 'os' -import { join } from 'path' -import { hooksInstalled, installHooks, hookEvents, uninstallHooks } from './codex' - -const HOOKS_PATH = join(homedir(), '.codex', 'hooks.json') - -beforeEach(() => { - fsState.files.clear() -}) - -describe('codex hook install / dedup', () => { - it('hooksInstalled() recognizes normalized entries with no _marker field', () => { - const data = { - hooks: { - SessionStart: [ - { - hooks: [ - { - type: 'command', - command: - "bash -c 'd=/tmp/harness-status; printf hi >> \"$d/$h.ndjson\"'", - timeout: 5 - } - ] - } - ] - } - } - fsState.files.set(HOOKS_PATH, JSON.stringify(data)) - expect(hooksInstalled()).toBe(true) - }) - - it('hooksInstalled() returns false when only user-authored hooks exist', () => { - fsState.files.set( - HOOKS_PATH, - JSON.stringify({ - hooks: { - SessionStart: [ - { hooks: [{ type: 'command', command: 'echo user hook', timeout: 5 }] } - ] - } - }) - ) - expect(hooksInstalled()).toBe(false) - }) - - it('installHooks() called twice yields exactly one harness entry per event', () => { - installHooks() - installHooks() - const data = JSON.parse(fsState.files.get(HOOKS_PATH) as string) - for (const event of hookEvents) { - const entries = data.hooks[event] - expect(entries).toHaveLength(1) - expect(entries[0].hooks[0].command).toContain('/tmp/harness-status') - } - }) - - it('installHooks() collapses pre-existing duplicates left by buggy passes', () => { - const dupEntry = { - hooks: [ - { - type: 'command', - command: - "bash -c 'd=/tmp/harness-status; printf hi >> \"$d/$h.ndjson\"'", - timeout: 5 - } - ] - } - const data: { hooks: Record } = { hooks: {} } - for (const event of hookEvents) { - data.hooks[event] = [dupEntry, dupEntry, dupEntry] - } - fsState.files.set(HOOKS_PATH, JSON.stringify(data)) - - installHooks() - - const after = JSON.parse(fsState.files.get(HOOKS_PATH) as string) - for (const event of hookEvents) { - expect(after.hooks[event]).toHaveLength(1) - } - }) - - it('installHooks() preserves user-authored hooks', () => { - const userHook = { - hooks: [{ type: 'command', command: 'echo user hook', timeout: 10 }] - } - fsState.files.set( - HOOKS_PATH, - JSON.stringify({ - hooks: { SessionStart: [userHook], PreToolUse: [userHook] } - }) - ) - - installHooks() - - const after = JSON.parse(fsState.files.get(HOOKS_PATH) as string) - expect(after.hooks.SessionStart).toContainEqual(userHook) - expect(after.hooks.PreToolUse).toContainEqual(userHook) - for (const event of hookEvents) { - const harnessEntries = (after.hooks[event] as Array<{ hooks: { command: string }[] }>).filter( - (e) => e.hooks.some((h) => h.command.includes('/tmp/harness-status')) - ) - expect(harnessEntries).toHaveLength(1) - } - }) - - it('uninstallHooks() removes harness entries but preserves user-authored hooks', () => { - installHooks() - const after = JSON.parse(fsState.files.get(HOOKS_PATH) as string) - after.hooks.SessionStart.push({ - hooks: [{ type: 'command', command: 'echo user hook' }] - }) - fsState.files.set(HOOKS_PATH, JSON.stringify(after)) - - uninstallHooks() - - const final = JSON.parse(fsState.files.get(HOOKS_PATH) as string) - expect(final.hooks?.SessionStart).toEqual([ - { hooks: [{ type: 'command', command: 'echo user hook' }] } - ]) - expect(final.hooks?.PreToolUse).toBeUndefined() - }) -}) diff --git a/src/main/agents/codex.ts b/src/main/agents/codex.ts index 7ff1ea7b..252fcc0a 100644 --- a/src/main/agents/codex.ts +++ b/src/main/agents/codex.ts @@ -1,22 +1,21 @@ -import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, appendFileSync } from 'fs' import { join } from 'path' import { homedir } from 'os' -import { log } from '../debug' -import { makeHookCommand } from '../hooks' +import { readdirSync, statSync } from 'fs' +import { stripHarnessEntriesFromHooksFile, legacyWorktreeHooksPath } from '../codex-plugin' import type { AgentSpawnOpts } from './index' function shellQuote(s: string): string { return "'" + s.replace(/'/g, "'\\''") + "'" } -// Codex strips unknown fields when it normalizes hooks.json, so dedup -// recognizes our entries by the status-dir path baked into the hook -// command instead of a sidecar marker. -const HARNESS_HOOK_COMMAND_SIGNATURE = '/tmp/harness-status' - export const defaultCommand = 'codex' export const assignsSessionId = false +// Codex's hook event names — used by AgentModule for parity with +// Claude's. Codex hooks now ship inside the bundled plugin (see +// resources/plugins/harness-status/hooks/hooks.json), so this list +// exists only to satisfy the interface; nothing in the install path +// reads it anymore. export const hookEvents = [ 'SessionStart', 'PreToolUse', @@ -25,136 +24,18 @@ export const hookEvents = [ 'Stop' ] -interface CodexHookEntry { - matcher?: string - hooks: { type: string; command: string; timeout?: number }[] -} - -interface CodexHooksFile { - hooks?: Record -} - -function globalHooksPath(): string { - return join(homedir(), '.codex', 'hooks.json') -} - -function worktreeHooksPath(worktreePath: string): string { - return join(worktreePath, '.codex', 'hooks.json') -} - -function readHooksFile(path: string): CodexHooksFile { - try { - return JSON.parse(readFileSync(path, 'utf-8')) - } catch { - return {} - } -} - -function writeHooksFile(path: string, data: CodexHooksFile): void { - const dir = join(path, '..') - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - writeFileSync(path, JSON.stringify(data, null, 2)) -} - -function makeHarnessHookEntry(command: string): CodexHookEntry { - return { - hooks: [{ type: 'command', command, timeout: 5 }] - } -} - -function isHarnessHookEntry(entry: CodexHookEntry): boolean { - return !!entry.hooks?.some( - (h) => typeof h.command === 'string' && h.command.includes(HARNESS_HOOK_COMMAND_SIGNATURE) - ) -} - -function removeOldHarnessEntries(entries: CodexHookEntry[]): CodexHookEntry[] { - return entries.filter((entry) => !isHarnessHookEntry(entry)) -} - -function ensureCodexHooksEnabled(): void { - const configPath = join(homedir(), '.codex', 'config.toml') - try { - const content = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '' - if (content.includes('codex_hooks')) return - const section = content.includes('[features]') ? '' : '\n[features]\n' - const line = 'codex_hooks = true\n' - appendFileSync(configPath, section + line) - log('hooks', 'enabled codex_hooks in ~/.codex/config.toml') - } catch (err) { - log('hooks', 'failed to enable codex_hooks', err instanceof Error ? err.message : err) - } -} - -export function hooksInstalled(): boolean { - const data = readHooksFile(globalHooksPath()) - const hooks = data.hooks - if (!hooks) return false - for (const entries of Object.values(hooks)) { - for (const entry of entries) { - if (isHarnessHookEntry(entry)) return true - } - } - return false -} - -export function installHooks(): void { - const path = globalHooksPath() - log('hooks', `installing Codex hooks into ${path}`) - - ensureCodexHooksEnabled() - - const data = readHooksFile(path) - if (!data.hooks) data.hooks = {} - - for (const event of Object.keys(data.hooks)) { - data.hooks[event] = removeOldHarnessEntries(data.hooks[event]) - } - - for (const event of hookEvents) { - if (!data.hooks[event]) data.hooks[event] = [] - data.hooks[event].push(makeHarnessHookEntry(makeHookCommand(event))) - } - - writeHooksFile(path, data) -} - -export function uninstallHooks(): void { - const path = globalHooksPath() - if (!existsSync(path)) return - const data = readHooksFile(path) - if (!data.hooks) return - for (const event of Object.keys(data.hooks)) { - data.hooks[event] = removeOldHarnessEntries(data.hooks[event]) - if (data.hooks[event].length === 0) delete data.hooks[event] - } - if (Object.keys(data.hooks).length === 0) delete data.hooks - writeHooksFile(path, data) - log('hooks', `uninstalled Codex hooks from ${path}`) -} - +/** Legacy one-shot strip — removes Harness entries from a worktree's + * .codex/hooks.json (the old install location, pre-plugin). Boot-time + * migration sweeps this for every worktree; the AgentModule contract + * also exposes it for the panes FSM's per-worktree initialization + * callback. After migration completes, both paths become no-ops. */ export function stripHooksFromWorktree(worktreePath: string): boolean { - const path = worktreeHooksPath(worktreePath) - if (!existsSync(path)) return false - const data = readHooksFile(path) - if (!data.hooks) return false - let changed = false - for (const event of Object.keys(data.hooks)) { - const before = data.hooks[event].length - data.hooks[event] = removeOldHarnessEntries(data.hooks[event]) - if (data.hooks[event].length !== before) changed = true - if (data.hooks[event].length === 0) delete data.hooks[event] - } - if (!changed) return false - if (Object.keys(data.hooks).length === 0) delete data.hooks - writeHooksFile(path, data) - log('hooks', `stripped legacy Harness Codex entries from ${path}`) - return true + return stripHarnessEntriesFromHooksFile(legacyWorktreeHooksPath(worktreePath)) } export function sessionFileExists(_cwd: string, sessionId: string): boolean { try { - const sessionsDir = join(homedir(), '.codex', 'sessions') + const sessionsDir = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions') const walkDir = (dir: string): boolean => { for (const entry of readdirSync(dir, { withFileTypes: true })) { if (entry.isDirectory()) { @@ -173,7 +54,7 @@ export function sessionFileExists(_cwd: string, sessionId: string): boolean { export function latestSessionId(_cwd: string): string | null { try { - const sessionsDir = join(homedir(), '.codex', 'sessions') + const sessionsDir = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions') let bestId: string | null = null let bestMtime = -Infinity const walkDir = (dir: string): void => { @@ -200,10 +81,43 @@ export function latestSessionId(_cwd: string): string | null { } export function buildSpawnArgs(opts: AgentSpawnOpts): string { - // Codex MCP is configured globally via ~/.codex/config.toml, not per-terminal - // flags. The mcpConfigPath is unused here but the MCP server was already - // registered by the prepareMcpForTerminal IPC call. + // The bundled plugin's static .mcp.json uses `${HARNESS_NODE_EXEC}` + // and friends, which Codex does NOT interpolate (verified against + // 0.133: Codex passes the literal templates to execve and strips + // most of the inherited env when spawning MCP subprocesses). + // codex-plugin.ts:neutralizeCachedMcpJson erases the harness-control + // entry from Codex's cached plugin copy so it doesn't try to spawn + // a process named `${HARNESS_NODE_EXEC}` and fail with ENOENT; + // instead we register the MCP server per-spawn via `-c` overrides + // with all values as literals. + // + // Plugin hooks require interactive trust before they fire — Codex + // shows a TUI "Hooks need review / Trust all and continue" prompt + // on first launch after a new/changed plugin install, then + // persists per-event trust hashes in ~/.codex/config.toml under + // [hooks.state]. There's no CLI flag or subcommand to drive this + // non-interactively. The Settings card surfaces guidance when the + // verification probe detects untrusted hooks. let cmd = opts.command + + if (opts.harnessControl) { + const hc = opts.harnessControl + // -c values must be valid TOML; strings need embedded quotes. + cmd += ` -c ${shellQuote(`mcp_servers.harness-control.command=${JSON.stringify(hc.execPath)}`)}` + cmd += ` -c ${shellQuote(`mcp_servers.harness-control.args=${JSON.stringify([hc.bridgePath])}`)}` + const envEntries = [ + `ELECTRON_RUN_AS_NODE="1"`, + `HARNESS_PORT=${JSON.stringify(String(hc.port))}`, + `HARNESS_TOKEN=${JSON.stringify(hc.token)}`, + `HARNESS_TERMINAL_ID=${JSON.stringify(hc.terminalId)}`, + `HARNESS_SESSION_ID=${JSON.stringify(hc.terminalId)}` + ] + if (hc.workspaceId) envEntries.push(`HARNESS_WORKTREE_ID=${JSON.stringify(hc.workspaceId)}`) + if (hc.repoRoot) envEntries.push(`HARNESS_REPO_ROOT=${JSON.stringify(hc.repoRoot)}`) + if (hc.isMain) envEntries.push(`HARNESS_IS_MAIN="1"`) + cmd += ` -c ${shellQuote(`mcp_servers.harness-control.env={${envEntries.join(',')}}`)}` + } + if (opts.model && !opts.command.includes('--model') && !opts.command.includes('-m ')) { cmd += ` --model ${shellQuote(opts.model)}` } diff --git a/src/main/agents/index.ts b/src/main/agents/index.ts index e2feaf16..5dac2ee2 100644 --- a/src/main/agents/index.ts +++ b/src/main/agents/index.ts @@ -11,10 +11,25 @@ export interface AgentSpawnOpts { initialPrompt?: string teleportSessionId?: string sessionName?: string - mcpConfigPath?: string | null model?: string | null systemPrompt?: string tuiFullscreen?: boolean + /** Harness-control MCP bridge runtime info. The Codex agent injects + * this as `-c mcp_servers.harness-control.*` overrides at spawn + * time because Codex's `.mcp.json` does no interpolation (see + * src/main/codex-plugin.ts:neutralizeCachedMcpJson). Claude consumes + * the same data via the plugin's `.mcp.json` template; populated + * for both agent kinds but only Codex reads it through this path. */ + harnessControl?: { + execPath: string + bridgePath: string + port: number + token: string + terminalId: string + workspaceId?: string + repoRoot?: string + isMain?: boolean + } } export interface AgentModule { @@ -24,17 +39,10 @@ export interface AgentModule { * CLI on first spawn (e.g. Claude's --session-id). If false, the agent * assigns its own ID and Harness discovers it from the first hook event. */ assignsSessionId: boolean - /** Install status hooks at the agent's user-scope settings file - * (~/.claude/settings.json for Claude, ~/.codex/hooks.json for Codex). - * The hook command is gated on $HARNESS_TERMINAL_ID so sessions spawned - * outside Harness are untouched. */ - installHooks(): void - hooksInstalled(): boolean - /** Remove only the Harness-marked entries from the user-scope settings file. - * Any user-authored hooks and unrelated keys survive. */ - uninstallHooks(): void /** Migration: strip legacy Harness entries from a single worktree's - * per-worktree settings file. Returns true if the file was modified. */ + * per-worktree settings file. Returns true if the file was modified. + * Claude wrote per-worktree entries before the global-install era; + * Codex has its own equivalent. Used by the boot migration sweep. */ stripHooksFromWorktree(worktreePath: string): boolean sessionFileExists(cwd: string, sessionId: string): boolean latestSessionId(cwd: string): string | null diff --git a/src/main/claude-plugin.test.ts b/src/main/claude-plugin.test.ts new file mode 100644 index 00000000..deb944b4 --- /dev/null +++ b/src/main/claude-plugin.test.ts @@ -0,0 +1,39 @@ +// Drift detector: the static hooks.json shipped under +// resources/plugins/harness-status/hooks/hooks.json must match what +// makeHookCommand() would generate today. If src/main/hooks.ts ever +// changes the hook command shape, this test fails so the static file +// gets regenerated in the same commit. + +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { join } from 'path' +import { makeHookCommand } from './hooks' + +const HOOK_EVENTS = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'Stop', + 'Notification' +] + +function expectedHooksJson(): string { + const hooks: Record = {} + for (const event of HOOK_EVENTS) { + hooks[event] = [ + { hooks: [{ type: 'command', command: makeHookCommand(event), timeout: 5 }] } + ] + } + return JSON.stringify({ hooks }, null, 2) + '\n' +} + +describe('bundled harness-status plugin', () => { + it('hooks.json matches makeHookCommand() output', () => { + // Resolve from cwd (vitest runs from the repo root) so this test + // doesn't break when the file gets compiled into out/ by tsc -b. + const path = join(process.cwd(), 'resources', 'plugins', 'harness-status', 'hooks', 'hooks.json') + const actual = readFileSync(path, 'utf-8') + expect(actual).toBe(expectedHooksJson()) + }) +}) diff --git a/src/main/claude-plugin.ts b/src/main/claude-plugin.ts new file mode 100644 index 00000000..45f9959d --- /dev/null +++ b/src/main/claude-plugin.ts @@ -0,0 +1,43 @@ +// Harness ships its Claude status hooks as a local Claude Code plugin +// bundled under resources/plugins/harness-status/. Every Claude spawn +// passes --plugin-dir so the plugin loads for Harness sessions +// only; sessions outside Harness never see it. Two benefits over the +// previous user-scope settings.json install: +// 1. Zero writes to user-owned files — no consent prompt, no merge +// against user-authored hooks. +// 2. Coupled to a single Harness release — no drift between what +// Harness expects on stdin and what hooks.json emits. +// +// The hook command still env-gates on $HARNESS_TERMINAL_ID as a second +// line of defense in case the bundle gets copied or referenced +// elsewhere. STATUS_DIR / makeHookCommand are the single source of +// truth; src/main/claude-plugin.test.ts asserts the static hooks.json +// matches what makeHookCommand would generate today so the two never +// drift silently. +// +// Path resolution mirrors src/main/mcp-config.ts:getBridgeScriptPath +// (packaged → process.resourcesPath, dev → relative to __dirname). The +// plugin tree is shipped via electron-builder's extraResources. + +import { join } from 'path' +import { isPackaged } from './paths' + +/** Absolute path to the bundled Harness status plugin. Pass this to + * every Claude spawn via --plugin-dir. */ +export function harnessPluginDir(): string { + if (isPackaged()) { + return join(process.resourcesPath, 'plugins', 'harness-status') + } + return join(__dirname, '..', '..', 'resources', 'plugins', 'harness-status') +} + +/** Absolute path to the marketplace root containing the bundled plugin. + * This is the parent of harnessPluginDir() and the dir that holds + * .agents/plugins/marketplace.json — pass to `codex plugin marketplace + * add` so Codex picks up the same tree. */ +export function harnessPluginMarketplaceRoot(): string { + if (isPackaged()) { + return join(process.resourcesPath, 'plugins') + } + return join(__dirname, '..', '..', 'resources', 'plugins') +} diff --git a/src/main/codex-plugin.test.ts b/src/main/codex-plugin.test.ts new file mode 100644 index 00000000..33457d5e --- /dev/null +++ b/src/main/codex-plugin.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const fsState: { files: Map } = { files: new Map() } + +vi.mock('fs', () => ({ + existsSync: (p: string) => fsState.files.has(p), + readFileSync: (p: string) => { + if (!fsState.files.has(p)) throw new Error(`ENOENT: ${p}`) + return fsState.files.get(p) as string + }, + writeFileSync: (p: string, data: string) => { + fsState.files.set(p, data) + } +})) + +vi.mock('./debug', () => ({ + log: () => {} +})) + +vi.mock('./claude-plugin', () => ({ + harnessPluginMarketplaceRoot: () => '/bundled/plugins' +})) + +import { + stripHarnessEntriesFromHooksFile, + legacyGlobalHooksPath, + legacyWorktreeHooksPath +} from './codex-plugin' + +beforeEach(() => { + fsState.files.clear() +}) + +describe('stripHarnessEntriesFromHooksFile', () => { + const SIG = '/tmp/harness-status' + const harnessEntry = { + hooks: [{ type: 'command', command: `bash -c 'd=${SIG}; …'`, timeout: 5 }] + } + const userEntry = { + hooks: [{ type: 'command', command: 'echo user hook' }] + } + + it('returns false when the file does not exist', () => { + expect(stripHarnessEntriesFromHooksFile('/nope/hooks.json')).toBe(false) + }) + + it('returns false when no hooks key is present', () => { + fsState.files.set('/x/hooks.json', JSON.stringify({})) + expect(stripHarnessEntriesFromHooksFile('/x/hooks.json')).toBe(false) + }) + + it('returns false when there are no harness entries', () => { + fsState.files.set( + '/x/hooks.json', + JSON.stringify({ hooks: { Stop: [userEntry] } }) + ) + expect(stripHarnessEntriesFromHooksFile('/x/hooks.json')).toBe(false) + // Untouched. + expect(JSON.parse(fsState.files.get('/x/hooks.json') as string)).toEqual({ + hooks: { Stop: [userEntry] } + }) + }) + + it('strips harness entries but preserves user-authored ones', () => { + fsState.files.set( + '/x/hooks.json', + JSON.stringify({ + hooks: { + Stop: [harnessEntry, userEntry], + PreToolUse: [harnessEntry] + } + }) + ) + expect(stripHarnessEntriesFromHooksFile('/x/hooks.json')).toBe(true) + const after = JSON.parse(fsState.files.get('/x/hooks.json') as string) + expect(after).toEqual({ hooks: { Stop: [userEntry] } }) + }) + + it('drops the hooks key entirely when every event becomes empty', () => { + fsState.files.set( + '/x/hooks.json', + JSON.stringify({ + hooks: { Stop: [harnessEntry], PreToolUse: [harnessEntry] } + }) + ) + expect(stripHarnessEntriesFromHooksFile('/x/hooks.json')).toBe(true) + const after = JSON.parse(fsState.files.get('/x/hooks.json') as string) + expect(after.hooks).toBeUndefined() + }) + + it('gracefully handles invalid JSON', () => { + fsState.files.set('/x/hooks.json', '{ not valid json }') + expect(stripHarnessEntriesFromHooksFile('/x/hooks.json')).toBe(false) + }) +}) + +describe('hook file path helpers', () => { + it('legacyGlobalHooksPath ends with .codex/hooks.json', () => { + expect(legacyGlobalHooksPath()).toMatch(/\.codex[\\/]hooks\.json$/) + }) + + it('legacyWorktreeHooksPath nests under the worktree', () => { + expect(legacyWorktreeHooksPath('/work/tree')).toMatch( + /^\/work\/tree[\\/]\.codex[\\/]hooks\.json$/ + ) + }) +}) diff --git a/src/main/codex-plugin.ts b/src/main/codex-plugin.ts new file mode 100644 index 00000000..f06b9f2e --- /dev/null +++ b/src/main/codex-plugin.ts @@ -0,0 +1,479 @@ +// Harness ships its status hooks for Codex as the same plugin tree that +// Claude consumes via --plugin-dir. Codex doesn't accept a --plugin-dir +// flag — instead, plugins live under a *marketplace* registered in +// ~/.codex/config.toml. We register the bundled marketplace + enable +// the plugin at boot via the `codex plugin marketplace add` and +// `codex plugin add` commands. Both write to config.toml, but cleanly: +// removable via the matching `remove` subcommands. +// +// Why every boot, not "once": +// Codex caches plugins under ~/.codex/plugins/cache// +// // and never refreshes automatically. Running +// `plugin add` on every Harness boot re-copies the bundled source +// into the cache so a fresh Harness release picks up immediately. +// Version-bump the plugin in .claude-plugin/plugin.json on every +// Harness release to evict the old cache dir cleanly. +// +// Why the same plugin works for both agents: +// Codex 0.133+ reads .claude-plugin/plugin.json when no .codex-plugin/ +// sibling exists. Hook event names (UserPromptSubmit/PreToolUse/…) and +// the hooks.json schema are identical to Claude's. Codex auto-loads +// the plugin's .mcp.json and resolves both ${PLUGIN_ROOT} and +// ${CLAUDE_PLUGIN_ROOT}, so the MCP bridge entry needs no changes. +// Skills under skills//SKILL.md are discovered the same way. + +import { spawnSync } from 'child_process' +import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' +import { log } from './debug' +import { harnessPluginMarketplaceRoot } from './claude-plugin' +import type { CodexPluginVerification } from '../shared/codex-plugin' + +export type { CodexPluginVerification } + +const MARKETPLACE_NAME = 'harness' +const PLUGIN_NAME = 'harness-status' + +function runCodex(codexCommand: string, args: string[]): { ok: boolean; output: string } { + // Wrap in a login shell so Homebrew/nvm/etc. paths resolve the same + // way as the user's terminal. path-fix.ts merges PATH at boot but + // some environments (headless, weird shells) still benefit from the + // explicit login wrap. + const cmd = [codexCommand, ...args].join(' ') + try { + const result = spawnSync('/bin/zsh', ['-ilc', cmd], { + encoding: 'utf-8', + timeout: 30_000 + }) + const output = (result.stdout || '') + (result.stderr || '') + if (result.status === 0) return { ok: true, output } + return { ok: false, output: output || `exited ${result.status}` } + } catch (err) { + return { ok: false, output: err instanceof Error ? err.message : String(err) } + } +} + +const verificationFailure = (message: string): CodexPluginVerification => ({ + ok: false, + pluginEnabled: false, + hooksPresent: false, + hooksTrusted: false, + message +}) + +/** Probe a presumed-installed plugin and return per-assertion booleans. + * Safe to call any time; never mutates state. */ +export function probeCodexPlugin(codexCommand: string): CodexPluginVerification { + const list = runCodex(codexCommand, [ + 'plugin', + 'list', + '--marketplace', + MARKETPLACE_NAME + ]) + const enabledLine = list.output + .split('\n') + .find((line) => line.startsWith(`${PLUGIN_NAME}@${MARKETPLACE_NAME}`)) + const pluginEnabled = !!enabledLine && /installed, enabled/.test(enabledLine) + + // Plugin cache lives at ~/.codex/plugins/cache/// + // /hooks/hooks.json. We probe the static path components and + // fall back to scanning for any version directory if the bundled + // version we'd guess doesn't exist yet. + const cacheBase = join(codexHome(), 'plugins', 'cache', MARKETPLACE_NAME, PLUGIN_NAME) + let hooksPresent = false + try { + if (existsSync(cacheBase)) { + const versions = readdirSync(cacheBase) + hooksPresent = versions.some((v) => existsSync(join(cacheBase, v, 'hooks', 'hooks.json'))) + } + } catch { + hooksPresent = false + } + + const hooksTrusted = areHooksTrusted(cacheBase) + + const ok = pluginEnabled && hooksPresent + return { + ok, + pluginEnabled, + hooksPresent, + hooksTrusted, + message: ok ? undefined : verificationMessage({ pluginEnabled, hooksPresent }) + } +} + +/** Check whether Codex has persisted trust for every event in the + * plugin's hooks.json. + * + * Codex stores per-event trust as + * [hooks.state."@:hooks/hooks.json::0:0"] + * trusted_hash = "sha256:…" + * in `${CODEX_HOME:-~/.codex}/config.toml`. The hash is over a + * normalized form we can't easily reproduce from the command string + * alone, so we verify by **key presence** rather than hash equality. + * Codex only writes the entry on a positive "Trust all and continue" + * decision, so presence == user-granted trust. If the underlying + * hook content later drifts, Codex itself will detect the + * current_hash mismatch at session start and re-prompt — we don't + * need to re-verify the hash ourselves. + * + * Returns false on any read/parse failure — treated as "not trusted" + * so the UI surfaces the issue rather than silently passing. */ +function areHooksTrusted(cacheBase: string): boolean { + // Find the most recent version dir under the cache so we know which + // events to look for in config.toml. + if (!existsSync(cacheBase)) return false + let hooksJson: string | null = null + try { + const versions = readdirSync(cacheBase).filter((v) => /\d/.test(v)) + if (versions.length === 0) return false + versions.sort() + const latest = versions[versions.length - 1] + const hooksPath = join(cacheBase, latest, 'hooks', 'hooks.json') + if (!existsSync(hooksPath)) return false + hooksJson = readFileSync(hooksPath, 'utf-8') + } catch { + return false + } + if (!hooksJson) return false + + let parsed: { hooks?: Record } + try { + parsed = JSON.parse(hooksJson) + } catch { + return false + } + if (!parsed.hooks) return false + // Limit verification to events Codex actually understands. Our + // hooks.json includes `Notification`, which Claude consumes but + // Codex ignores entirely — it never writes a [hooks.state] entry + // for unrecognized events, so requiring trust for them would + // permanently fail the check. List is from Codex 0.133 binary + // strings; keep in sync if Codex adds more. + const CODEX_HOOK_EVENTS = new Set([ + 'pre_tool_use', + 'post_tool_use', + 'session_start', + 'user_prompt_submit', + 'stop', + 'subagent_start', + 'subagent_stop', + 'pre_compact', + 'post_compact', + 'permission_request' + ]) + const events = Object.keys(parsed.hooks).filter((e) => + CODEX_HOOK_EVENTS.has(toSnakeCase(e)) + ) + if (events.length === 0) return false + + const configPath = join(codexHome(), 'config.toml') + let config = '' + try { + config = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '' + } catch { + return false + } + + return events.every((event) => { + // Codex stores event names snake_cased in trust keys. + const snake = toSnakeCase(event) + // Match the [hooks.state."…"] block for this event and confirm it + // has a non-empty trusted_hash. We don't pin the index suffix to + // 0:0 because Codex might in principle use other index pairs for + // multi-entry events — we only have one entry per event today, + // but be liberal in what we accept here. + const blockRe = new RegExp( + `^\\[hooks\\.state\\."${PLUGIN_NAME}@${MARKETPLACE_NAME}:hooks/hooks\\.json:${snake}:[0-9]+:[0-9]+"\\][^\\n]*\\n(?:(?!^\\[)[^\\n]*\\n?)*?trusted_hash\\s*=\\s*"sha256:[0-9a-f]+"`, + 'm' + ) + return blockRe.test(config) + }) +} + +function toSnakeCase(pascal: string): string { + return pascal.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase() +} + +function codexHome(): string { + return process.env.CODEX_HOME || join(homedir(), '.codex') +} + +/** Erase the harness-control entry from the cached `.mcp.json` after + * `codex plugin add` materializes it. + * + * Why: the plugin's source `.mcp.json` is shared with Claude (which + * reads it via --plugin-dir) and uses `${HARNESS_NODE_EXEC}` / + * `${HARNESS_PORT}` / etc. interpolation that Claude expands at MCP + * launch time. **Codex does no interpolation at all** — empirically + * verified: `${CLAUDE_PLUGIN_ROOT}`, `${PLUGIN_ROOT}`, and arbitrary + * process env vars all pass through to `execve` as literal strings + * (Codex 0.133, May 2026). Worse, Codex spawns MCP subprocesses with + * a clean env — only HOME/PATH/PWD/USER/LANG inherit, so even if the + * command resolved, the bridge would have no port/token to dial back. + * + * Rather than maintain a Codex-specific .mcp.json variant or pollute + * the source with literal absolute paths that break under packaging, + * we strip the MCP definition from Codex's view entirely and inject + * it per-session via `-c mcp_servers.harness-control.*` overrides on + * the Codex spawn command line (see src/main/agents/codex.ts — + * buildSpawnArgs). The plugin still ships hooks + skills to Codex + * unchanged. */ +function neutralizeCachedMcpJson(): void { + const cacheBase = join(codexHome(), 'plugins', 'cache', MARKETPLACE_NAME, PLUGIN_NAME) + if (!existsSync(cacheBase)) return + let versions: string[] + try { + versions = readdirSync(cacheBase) + } catch { + return + } + for (const version of versions) { + const mcpPath = join(cacheBase, version, '.mcp.json') + if (!existsSync(mcpPath)) continue + try { + writeFileSync(mcpPath, JSON.stringify({ mcpServers: {} }, null, 2)) + } catch (err) { + log( + 'codex-plugin', + `failed to neutralize cached ${mcpPath}: ${err instanceof Error ? err.message : err}` + ) + } + } +} + +function verificationMessage(fields: { + pluginEnabled: boolean + hooksPresent: boolean +}): string { + const failed: string[] = [] + if (!fields.pluginEnabled) failed.push('plugin not enabled') + if (!fields.hooksPresent) failed.push('hooks file missing from cache') + return failed.join(', ') +} + +/** Ensure the bundled Harness marketplace + plugin are registered and + * enabled, then immediately probe Codex to verify the install took. + * Idempotent — safe to call on every Harness boot. Logs but never + * throws; missing codex or a failed step surfaces as + * `verification.ok === false` with a human-readable `message`. */ +export function installCodexPlugin(codexCommand: string): CodexPluginVerification { + const root = harnessPluginMarketplaceRoot() + log('codex-plugin', `registering marketplace from ${root}`) + + const mkt = runCodex(codexCommand, ['plugin', 'marketplace', 'add', JSON.stringify(root)]) + if (!mkt.ok) { + // codex may print "marketplace already exists" — treat as success. + if (!/already/i.test(mkt.output)) { + const msg = `marketplace add failed: ${mkt.output.trim()}` + log('codex-plugin', msg) + return verificationFailure(msg) + } + } + + const add = runCodex(codexCommand, ['plugin', 'add', `${PLUGIN_NAME}@${MARKETPLACE_NAME}`]) + if (!add.ok) { + const msg = `plugin add failed: ${add.output.trim()}` + log('codex-plugin', msg) + return verificationFailure(msg) + } + + ensureCodexHooksFeatureEnabled() + neutralizeCachedMcpJson() + + const verification = probeCodexPlugin(codexCommand) + log( + 'codex-plugin', + verification.ok + ? 'installed, enabled, and verified' + : `installed but verification failed: ${verification.message}` + ) + return verification +} + +/** Remove the bundled marketplace + plugin from the user's Codex + * install. Idempotent; logs but doesn't throw. + * + * Three steps: + * 1. `codex plugin remove` — also removes the cache dir. + * 2. `codex plugin marketplace remove` — drops the [marketplaces.harness] + * table from ~/.codex/config.toml. + * 3. Strip leftover `[hooks.state]` entries pointing at our (now-deleted) + * cache path AND entries whose `trusted_hash` matches one of our + * hook commands' sha256 (handles legacy ~/.codex/hooks.json install + * hashes that survived the migration). This ensures a reinstall + * re-prompts for trust review rather than silently inheriting the + * prior decision via content-hash match. */ +export function uninstallCodexPlugin(codexCommand: string): void { + runCodex(codexCommand, ['plugin', 'remove', `${PLUGIN_NAME}@${MARKETPLACE_NAME}`]) + runCodex(codexCommand, ['plugin', 'marketplace', 'remove', MARKETPLACE_NAME]) + stripHarnessHookTrustEntries() + log('codex-plugin', 'uninstalled') +} + +/** Remove `[hooks.state]` entries from ~/.codex/config.toml that + * belong to Harness's plugin install. Codex keys plugin trust entries + * as `[hooks.state."@:hooks/hooks.json:::"]`, + * so we match by the `harness-status@harness:` prefix — unambiguous, + * no risk of clobbering user-authored hook trust. + * + * We don't try to also strip legacy `~/.codex/hooks.json` trust + * entries here: the prior install path's keys (`:event:0:0`) + * could in principle match user-authored hooks if the user still keeps + * their own entries there, and Codex's `trusted_hash` is a normalized + * form we can't replicate to disambiguate by content. The pre-plugin + * migration already strips the hook entries themselves; the orphaned + * trust hashes left behind are harmless. */ +function stripHarnessHookTrustEntries(): void { + const configPath = join(codexHome(), 'config.toml') + if (!existsSync(configPath)) return + let original: string + try { + original = readFileSync(configPath, 'utf-8') + } catch (err) { + log( + 'codex-plugin', + `read config.toml for trust strip failed: ${err instanceof Error ? err.message : err}` + ) + return + } + + const ownedPrefix = `${PLUGIN_NAME}@${MARKETPLACE_NAME}:` + + // [hooks.state."KEY"] blocks span until the next [section] or EOF. + // Match each block, decide whether to drop it, then reassemble. + const blockRe = + /^\[hooks\.state\.(?:"([^"]+)"|'([^']+)'|([^\]\s]+))\][^\n]*\n((?:(?!^\[)[^\n]*\n?)*)/gm + const dropped: string[] = [] + const next = original.replace(blockRe, (match, dq, sq, bare) => { + const key = (dq ?? sq ?? bare ?? '') as string + if (key.startsWith(ownedPrefix)) { + dropped.push(key) + return '' + } + return match + }) + + if (dropped.length === 0) return + // Squash any blank-line runs the strip left behind. + const cleaned = next.replace(/\n{3,}/g, '\n\n') + try { + writeFileSync(configPath, cleaned) + log( + 'codex-plugin', + `stripped ${dropped.length} [hooks.state] trust entries: ${dropped.join(', ')}` + ) + } catch (err) { + log( + 'codex-plugin', + `write config.toml for trust strip failed: ${err instanceof Error ? err.message : err}` + ) + } +} + +/** Codex 0.133 deprecated `[features].codex_hooks` in favor of + * `[features].hooks`. Without it, the plugin's hooks.json is parsed + * but the hooks never fire. We rewrite the config.toml to use the + * new key (and strip the deprecated alias if present) so users on + * current Codex don't see the deprecation warning on every spawn. */ +export function ensureCodexHooksFeatureEnabled(): void { + const path = join(codexHome(), 'config.toml') + let content = '' + try { + content = existsSync(path) ? readFileSync(path, 'utf-8') : '' + } catch (err) { + log('codex-plugin', `failed to read config.toml: ${err instanceof Error ? err.message : err}`) + return + } + + let next = content + // Drop deprecated alias if present anywhere in [features]. + next = next.replace(/^\s*codex_hooks\s*=.*$\n?/gm, '') + + if (/^\s*hooks\s*=\s*true/m.test(next)) { + if (next !== content) { + try { + writeFileSync(path, next) + } catch (err) { + log( + 'codex-plugin', + `failed to update config.toml: ${err instanceof Error ? err.message : err}` + ) + } + } + return + } + + // Insert under [features]; create the section if missing. + if (/^\[features\]/m.test(next)) { + next = next.replace(/(^\[features\][^\n]*\n)/m, '$1hooks = true\n') + } else { + next = next.trimEnd() + '\n\n[features]\nhooks = true\n' + } + + try { + writeFileSync(path, next) + log('codex-plugin', 'enabled [features].hooks in ~/.codex/config.toml') + } catch (err) { + log('codex-plugin', `failed to write config.toml: ${err instanceof Error ? err.message : err}`) + } +} + +/** One-shot migration from the prior `~/.codex/hooks.json` install + * path. Strips any Harness-installed entries (recognized by the + * /tmp/harness-status signature baked into the command) and removes + * empty event arrays. Returns true if anything was changed. */ +export function stripHarnessEntriesFromHooksFile(path: string): boolean { + if (!existsSync(path)) return false + let raw: string + try { + raw = readFileSync(path, 'utf-8') + } catch { + return false + } + let data: { hooks?: Record }>> } + try { + data = JSON.parse(raw) + } catch { + return false + } + if (!data.hooks) return false + + let changed = false + const SIG = '/tmp/harness-status' + for (const event of Object.keys(data.hooks)) { + const before = data.hooks[event].length + data.hooks[event] = data.hooks[event].filter( + (entry) => + !entry.hooks?.some((h) => typeof h.command === 'string' && h.command.includes(SIG)) + ) + if (data.hooks[event].length !== before) changed = true + if (data.hooks[event].length === 0) delete data.hooks[event] + } + if (!changed) return false + if (Object.keys(data.hooks).length === 0) delete data.hooks + + try { + writeFileSync(path, JSON.stringify(data, null, 2)) + log('codex-plugin', `stripped legacy Harness entries from ${path}`) + return true + } catch (err) { + log( + 'codex-plugin', + `failed to write stripped hooks file: ${err instanceof Error ? err.message : err}` + ) + return false + } +} + +/** Global hooks file path — used by the boot migration to locate the + * legacy install. */ +export function legacyGlobalHooksPath(): string { + return join(codexHome(), 'hooks.json') +} + +/** Per-worktree hooks file path. */ +export function legacyWorktreeHooksPath(worktreePath: string): string { + return join(worktreePath, '.codex', 'hooks.json') +} diff --git a/src/main/harness-control-env.ts b/src/main/harness-control-env.ts new file mode 100644 index 00000000..376df737 --- /dev/null +++ b/src/main/harness-control-env.ts @@ -0,0 +1,47 @@ +// Env vars consumed by the bundled Harness status plugin's .mcp.json +// when it spawns the harness-control MCP bridge. The plugin file +// references these via ${...} interpolation at Claude launch time, and +// the bridge subprocess inherits them through its parent (Claude). +// +// Separated from src/main/mcp-config.ts so the env-building logic stays +// in one place even after we deleted the per-terminal config-file +// writer. + +import type { CallerScope } from './control-server' + +export interface HarnessControlEnvDeps { + /** process.execPath at runtime (Electron's binary when running via + * ELECTRON_RUN_AS_NODE=1, plain Node when headless). The plugin + * invokes this to run the bridge script. */ + execPath: string + /** Control server port + token. Plugin references them as + * ${HARNESS_PORT} / ${HARNESS_TOKEN}. */ + port: number + token: string + /** The terminal id the bridge should claim. Surfaced under + * HARNESS_MCP_TERMINAL_ID so it doesn't collide with + * HARNESS_TERMINAL_ID (which gates the status hooks — json-mode tabs + * intentionally scrub that to avoid double-tracking, but still want + * the MCP bridge to know which session it's bound to). */ + terminalId: string + /** Optional caller scope. Plugin references the fields via + * ${HARNESS_WORKTREE_ID:-} etc. with empty-string defaults. */ + scope: CallerScope | null +} + +/** Build the env block that must be present on Claude's spawn env so the + * bundled plugin's .mcp.json can resolve its ${...} placeholders. */ +export function buildHarnessControlEnv(deps: HarnessControlEnvDeps): Record { + const env: Record = { + HARNESS_NODE_EXEC: deps.execPath, + HARNESS_PORT: String(deps.port), + HARNESS_TOKEN: deps.token, + HARNESS_MCP_TERMINAL_ID: deps.terminalId + } + if (deps.scope) { + env.HARNESS_WORKTREE_ID = deps.scope.worktreePath + env.HARNESS_REPO_ROOT = deps.scope.repoRoot + if (deps.scope.isMain) env.HARNESS_IS_MAIN = '1' + } + return env +} diff --git a/src/main/hooks.ts b/src/main/hooks.ts index c39d7f53..5000eb35 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -79,6 +79,11 @@ function readPendingTool(p: Record | null): PendingTool | null function deriveStatus(terminalId: string, ev: HookEvent): StatusUpdate | null { switch (ev.event) { + case 'SessionStart': + // Fires when a session starts or resumes. Nothing is running yet, so + // the tab is idle. Its payload also carries session_id, which the + // generic discovery in tailLog picks up before the first prompt. + return { status: 'waiting', pendingTool: null } case 'PreToolUse': { const tool = readPendingTool(ev.payload) if (tool) lastPreTool.set(terminalId, tool) diff --git a/src/main/index.ts b/src/main/index.ts index 32718428..af1cd2bd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -76,7 +76,19 @@ import { DEFAULT_PR_REVIEW_PROMPT } from '../shared/state/settings' import { watchStatusDir } from './hooks' -import { getAgent, type AgentKind } from './agents' +import { getAgent, type AgentKind, type AgentSpawnOpts } from './agents' +import * as codexAgent from './agents/codex' +import { + installCodexPlugin, + uninstallCodexPlugin, + probeCodexPlugin, + stripHarnessEntriesFromHooksFile, + legacyGlobalHooksPath, + legacyWorktreeHooksPath, + type CodexPluginVerification +} from './codex-plugin' +import { harnessPluginDir, harnessPluginMarketplaceRoot } from './claude-plugin' +import { stripGlobalHooks as stripGlobalClaudeHooks } from './agents/claude' import { buildClaudeLaunchSettings } from './claude-launch' import { HARNESS_REPO_OWNER, HARNESS_REPO_NAME } from '../shared/constants' import { readRecentDebugLog } from './debug' @@ -87,7 +99,8 @@ import { listDir as fsListDir, resolveHome as fsResolveHome } from './fs-listing import { listConfiguredHosts } from './ssh-config' import { SshTunnelManager } from './ssh-tunnel-manager' import { startControlServer } from './control-server' -import { writeMcpConfigForTerminal, pruneMcpConfigs, getBridgeScriptPath } from './mcp-config' +import { pruneMcpConfigs } from './mcp-config' +import { buildHarnessControlEnv } from './harness-control-env' import { getControlServerInfo } from './control-server' import { recordActivity, getActivityLog, clearAllActivity, clearActivityForWorktree, sealAllActive, touchActivityMeta, finalizeActivity, type ActivityState, type PRState } from './activity' import { log, getLogFilePath } from './debug' @@ -142,6 +155,17 @@ if (runtime === 'electron') { // Covers both Electron-from-Dock and headless-via-ssh/systemd, both of // which can start with a stripped PATH. No-op outside of macOS today; // see path-fix.ts. +// +// CODEX_HOME alignment: Codex (Rust binary) resolves `~` via the +// system passwd database (getpwuid) rather than $HOME on macOS, so +// our isolated $HOME doesn't propagate to it without an explicit +// CODEX_HOME. Set it from $HOME unless the user already pinned a +// different value — preserves explicit overrides, fixes isolation, +// and is a no-op for everyone else (CODEX_HOME would resolve to the +// same path Codex picks by default). +if (!process.env.CODEX_HOME && process.env.HOME) { + process.env.CODEX_HOME = join(process.env.HOME, '.codex') +} void fixPathFromLoginShell().then(bootLocal) // Wrap the entire local-mode boot in a function so the remote-mode @@ -326,7 +350,6 @@ const jsonClaudeManager = new JsonClaudeManager(store, { getClaudeEnvVars: () => store.getSnapshot().state.settings.claudeEnvVars || {}, getControlServer: () => getControlServerInfo(), - getControlBridgeScriptPath: () => getBridgeScriptPath(), isHarnessMcpEnabled: () => store.getSnapshot().state.settings.harnessMcpEnabled !== false, getCallerScope: (sessionId) => { @@ -868,23 +891,20 @@ const activityDeriver = new ActivityDeriver(store) // drives panesFSM.sleepJsonClaudeTab. const autoSleepMonitor = new AutoSleepMonitor(store, panesFSM) -/** Install agent status hooks at the user-scope settings file for both - * supported agents. Called once when consent flips to 'accepted'. The - * hook command is env-gated on $HARNESS_TERMINAL_ID, so it no-ops for - * sessions started outside Harness. */ -function installHooksGlobally(): void { - // installHooks() is idempotent — it strips any existing Harness entries - // before writing a fresh one — so calling it unconditionally also - // collapses duplicate entries left by earlier buggy install passes. - for (const agent of [getAgent('claude'), getAgent('codex')]) { - agent.installHooks() - } +/** Register + enable the bundled Harness plugin in the user's Codex + * install. Codex (0.133+) has no --plugin-dir equivalent, so we + * register a local marketplace and install the plugin from it via + * the `codex plugin` subcommands. Both write to ~/.codex/config.toml + * but are cleanly reversible. Idempotent — safe to call on every boot; + * the plugin cache is re-materialized so a fresh Harness release picks + * up the bundled changes immediately. See src/main/codex-plugin.ts + * for the full rationale. */ +function installCodexPluginNow(): CodexPluginVerification { + return installCodexPlugin(config.codexCommand || codexAgent.defaultCommand) } -function uninstallHooksGlobally(): void { - for (const agent of [getAgent('claude'), getAgent('codex')]) { - agent.uninstallHooks() - } +function uninstallCodexPluginNow(): void { + uninstallCodexPlugin(config.codexCommand || codexAgent.defaultCommand) } /** One-shot boot migration: for every non-main worktree that has a real @@ -1683,6 +1703,11 @@ function registerIpcHandlers(): void { return true }) + transport.onRequest( + 'codex:getMarketplaceRoot', + () => harnessPluginMarketplaceRoot() + ) + transport.onRequest('config:setCodexCommand', (_ctx, command: string) => { const trimmed = command.trim() if (!trimmed || trimmed === 'codex') { @@ -1944,12 +1969,6 @@ function registerIpcHandlers(): void { return true }) - transport.onRequest('mcp:prepareForTerminal', (_ctx, terminalId: string): string | null => { - if (config.harnessMcpEnabled === false) return null - if (!terminalId) return null - return writeMcpConfigForTerminal(terminalId, resolveCallerScope(terminalId)) - }) - transport.onRequest('config:setWsTransportEnabled', (_ctx, enabled: boolean) => { if (enabled) { config.wsTransportEnabled = true @@ -2459,10 +2478,6 @@ function registerIpcHandlers(): void { const command = kind === 'claude' ? (config.claudeCommand || agent.defaultCommand) : (config.codexCommand || agent.defaultCommand) - const mcpConfigPath = writeMcpConfigForTerminal( - opts.terminalId, - resolveCallerScope(opts.terminalId) - ) const override = opts.modelOverride && opts.modelOverride.trim() ? opts.modelOverride.trim() : undefined let systemPrompt: string | undefined @@ -2482,7 +2497,29 @@ function registerIpcHandlers(): void { model = override || config.codexModel || null } - return agent.buildSpawnArgs({ ...opts, command, mcpConfigPath, model, systemPrompt, tuiFullscreen }) + // Codex: assemble the harness-control MCP runtime info so the + // agent module can render `-c mcp_servers.harness-control.*` + // overrides. Claude doesn't need this — it consumes the plugin's + // .mcp.json directly via --plugin-dir. + let harnessControl: AgentSpawnOpts['harnessControl'] + if (kind === 'codex' && config.harnessMcpEnabled !== false) { + const controlInfo = getControlServerInfo() + if (controlInfo) { + const scope = resolveCallerScope(opts.terminalId) + harnessControl = { + execPath: process.execPath, + bridgePath: join(harnessPluginDir(), 'servers', 'mcp-bridge.js'), + port: controlInfo.port, + token: controlInfo.token, + terminalId: opts.terminalId, + workspaceId: scope?.worktreePath, + repoRoot: scope?.repoRoot, + isMain: scope?.isMain + } + } + } + + return agent.buildSpawnArgs({ ...opts, command, model, systemPrompt, tuiFullscreen, harnessControl }) } ) @@ -2592,15 +2629,18 @@ function registerIpcHandlers(): void { } ) - // Hooks. Install/uninstall happen once at user scope — the hook command - // is env-gated on $HARNESS_TERMINAL_ID so sessions spawned outside - // Harness are unaffected. + // Hooks consent — scoped to Codex only. Claude hooks ride along via + // --plugin-dir (see src/main/claude-plugin.ts), so no user file is + // touched and no consent is needed. Codex consumes the same bundled + // plugin tree but has to be registered via `codex plugin marketplace + // add` + `codex plugin add`, both of which write entries to + // ~/.codex/config.toml — hence the consent prompt. transport.onRequest('hooks:accept', (_ctx) => { - installHooksGlobally() + const verification = installCodexPluginNow() config.hooksConsent = 'accepted' saveConfig(config) store.dispatch({ type: 'hooks/consentChanged', payload: 'accepted' }) - return true + return verification }) transport.onRequest('hooks:decline', (_ctx) => { @@ -2611,13 +2651,19 @@ function registerIpcHandlers(): void { }) transport.onRequest('hooks:uninstall', (_ctx) => { - uninstallHooksGlobally() + uninstallCodexPluginNow() config.hooksConsent = 'pending' saveConfig(config) store.dispatch({ type: 'hooks/consentChanged', payload: 'pending' }) return true }) + // Probe-only — used by the Settings card to refresh verification + // status without re-running the install. + transport.onRequest('hooks:verify', (_ctx) => { + return probeCodexPlugin(config.codexCommand || codexAgent.defaultCommand) + }) + // shell:openExternal lives in desktop-shell.ts (Electron's `shell` // module). Web clients open links via `window.open` directly. @@ -2703,9 +2749,30 @@ function registerIpcHandlers(): void { 'pty:create', (ctx, id: string, cwd: string, cmd: string, args: string[], agentKind?: string, cols?: number, rows?: number) => { const isAgent = !!agentKind - const extraEnv = agentKind === 'claude' ? config.claudeEnvVars + const userEnv = agentKind === 'claude' ? config.claudeEnvVars : agentKind === 'codex' ? config.codexEnvVars : undefined + // Both Claude and Codex agent tabs load the same bundled Harness + // plugin (Claude via --plugin-dir, Codex via its plugin + // marketplace). The plugin's .mcp.json interpolates these env + // vars at launch so the bridge subprocess can dial back to the + // control server. Non-agent shell tabs don't need these. + let controlEnv: Record | undefined + if (isAgent && config.harnessMcpEnabled !== false) { + const controlInfo = getControlServerInfo() + if (controlInfo) { + controlEnv = buildHarnessControlEnv({ + execPath: process.execPath, + port: controlInfo.port, + token: controlInfo.token, + terminalId: id, + scope: resolveCallerScope(id) + }) + } + } + const extraEnv = userEnv || controlEnv + ? { ...(userEnv || {}), ...(controlEnv || {}) } + : undefined const existed = ptyManager.hasTerminal(id) ptyManager.create(id, cwd, cmd, args, extraEnv, !isAgent, cols, rows) if (!existed) { @@ -3556,67 +3623,82 @@ async function runBoot(): Promise { announcementsPoller.start() - // Seed hooks.consent from disk and migrate legacy per-worktree hooks - // to a single user-scope install. Runs once per app install; migrated - // state sticks via config.hooksMigratedToGlobal. + // Boot-time hooks setup. + // + // Both agents now consume the same bundled plugin tree under + // resources/plugins/harness-status/. Claude loads it via --plugin-dir + // on every spawn (no user-file install, no consent needed). Codex + // requires the marketplace to be registered with `codex plugin + // marketplace add` and the plugin enabled with `codex plugin add` — + // both write to ~/.codex/config.toml, hence the consent flow. + // + // Three one-shot migration sweeps run if their corresponding + // *Migrated flag isn't set, then never again: + // - hooksMigratedToPlugin: strips Harness entries from + // ~/.claude/settings.json and per-worktree + // .claude/settings.local.json files (Claude legacy). + // - codexPluginMigrated: strips Harness entries from + // ~/.codex/hooks.json and per-worktree .codex/hooks.json + // (Codex legacy install path). + // + // After migration: if Codex consent is 'accepted', we re-run the + // plugin install on every boot (idempotent). Codex caches plugins + // and never auto-refreshes, so re-running on each boot is the + // cheapest way to guarantee a fresh Harness release picks up its + // updated plugin contents. void (async () => { const claudeAgent = getAgent('claude') - const codexAgent = getAgent('codex') - // 1. Decide what the user's previous consent was. - // - Explicit persisted value wins (including 'declined'). - // - Otherwise infer from the current state of disk: any global - // install implies 'accepted'; otherwise scan worktrees for - // legacy per-worktree markers as evidence of a prior accept. - let consent: 'pending' | 'accepted' | 'declined' | undefined = config.hooksConsent - if (!consent) { - if (claudeAgent.hooksInstalled() || codexAgent.hooksInstalled()) { - consent = 'accepted' - } else { - let foundLegacy = false - for (const root of config.repoRoots || []) { - const trees = await listWorktrees(root).catch(() => []) - for (const wt of trees) { - // Probe via strip helper dry-run: we check existence of the - // per-worktree file + its contents cheaply here by attempting - // a strip and rolling back mentally — actually easier to just - // run the strip and treat the "changed" bit as evidence. - if (claudeAgent.stripHooksFromWorktree(wt.path)) foundLegacy = true - if (codexAgent.stripHooksFromWorktree(wt.path)) foundLegacy = true - } - } - consent = foundLegacy ? 'accepted' : 'pending' - if (foundLegacy) { - // Migration already happened above as a side-effect. - config.hooksMigratedToGlobal = true + // 1. Claude legacy-install cleanup (one-shot). + if (!config.hooksMigratedToPlugin) { + stripGlobalClaudeHooks() + for (const root of config.repoRoots || []) { + const trees = await listWorktrees(root).catch(() => []) + for (const wt of trees) { + claudeAgent.stripHooksFromWorktree(wt.path) } } - config.hooksConsent = consent + config.hooksMigratedToPlugin = true saveConfig(config) } - // 2. If user previously accepted but the global install is missing - // (fresh upgrade), install now so status tracking keeps working. - if (consent === 'accepted') { - installHooksGlobally() - } - - // 3. Run the one-shot migration sweep to strip legacy per-worktree - // hooks — needed when config.hooksConsent was already persisted - // (explicit path above didn't run the sweep) but we haven't - // swept yet. - if (!config.hooksMigratedToGlobal) { + // 2. Codex consent resolution + legacy hooks.json sweep. + // Explicit persisted value wins; else infer 'accepted' from the + // presence of legacy global or per-worktree Codex hook entries + // (handles users who consented before the plugin switch); else + // 'pending'. The legacy sweep is one-shot. + let foundLegacyCodexHooks = false + if (!config.codexPluginMigrated) { + if (stripHarnessEntriesFromHooksFile(legacyGlobalHooksPath())) { + foundLegacyCodexHooks = true + } for (const root of config.repoRoots || []) { const trees = await listWorktrees(root).catch(() => []) for (const wt of trees) { - claudeAgent.stripHooksFromWorktree(wt.path) - codexAgent.stripHooksFromWorktree(wt.path) + if (stripHarnessEntriesFromHooksFile(legacyWorktreeHooksPath(wt.path))) { + foundLegacyCodexHooks = true + } } } - config.hooksMigratedToGlobal = true + config.codexPluginMigrated = true + saveConfig(config) + } + + let consent: 'pending' | 'accepted' | 'declined' | undefined = config.hooksConsent + if (!consent) { + consent = foundLegacyCodexHooks ? 'accepted' : 'pending' + config.hooksConsent = consent saveConfig(config) } + // 3. Always re-install the plugin when accepted. Idempotent; + // Codex's plugin cache never auto-refreshes, so running on every + // boot is how we guarantee a fresh Harness release lands the + // updated plugin contents in ~/.codex/plugins/cache. + if (consent === 'accepted') { + installCodexPluginNow() + } + store.dispatch({ type: 'hooks/consentChanged', payload: consent }) })() diff --git a/src/main/json-claude-manager.test.ts b/src/main/json-claude-manager.test.ts index 17e81a5a..c625ea6b 100644 --- a/src/main/json-claude-manager.test.ts +++ b/src/main/json-claude-manager.test.ts @@ -82,7 +82,6 @@ describe('JsonClaudeManager', () => { closeApprovalSession: vi.fn(), getClaudeEnvVars: () => ({}), getControlServer: () => null, - getControlBridgeScriptPath: () => '/tmp/bridge.js', isHarnessMcpEnabled: () => false, getCallerScope: () => null, getLaunchSettings: () => launchSettings diff --git a/src/main/json-claude-manager.ts b/src/main/json-claude-manager.ts index b8506fa5..c32c3de3 100644 --- a/src/main/json-claude-manager.ts +++ b/src/main/json-claude-manager.ts @@ -22,7 +22,9 @@ import { existsSync, readFileSync, renameSync, writeFileSync } from 'fs' import { createRequire } from 'module' import { homedir } from 'os' import { dirname, join, sep } from 'path' -import { isPackaged, resolveBundledMcpScript } from './paths' +import { resolveBundledMcpScript } from './paths' +import { harnessPluginDir } from './claude-plugin' +import { buildHarnessControlEnv } from './harness-control-env' import type { Store } from './store' import type { JsonClaudeChatEntry, @@ -101,14 +103,11 @@ export interface JsonClaudeManagerOptions { getApprovalSocketPath: (sessionId: string) => string closeApprovalSession: (sessionId: string) => void getClaudeEnvVars: () => Record - /** Looked up from main when building the inline MCP config. Returning - * null means the harness-control bridge isn't injected (settings flag - * off, or control server not yet up). */ + /** Looked up from main when building the env vars the bundled plugin's + * .mcp.json interpolates. Returning null means the harness-control + * bridge isn't injected (settings flag off, or control server not + * yet up). */ getControlServer: () => JsonClaudeControlServerInfo | null - /** Returns the bundled harness-control bridge script path. Lives in - * resources/ same as permission-prompt-mcp.js — index.ts already - * knows how to resolve it via getBridgeScriptPath(). */ - getControlBridgeScriptPath: () => string /** True when the user has disabled the harness-control MCP via * settings.harnessMcpEnabled. Skips the bridge entry entirely. */ isHarnessMcpEnabled: () => boolean @@ -403,8 +402,13 @@ export class JsonClaudeManager { // Claude raises via --permission-prompt-tool. Tool is 'approve'. // * harness-control: the same MCP bridge used by xterm-backed // Claude tabs, exposing harness-control tools (worktree mgmt, - // browser tabs, shell tabs, etc.). Skipped when settings has - // harnessMcpEnabled=false or the control server isn't up. + // browser tabs, shell tabs, etc.). Defined by the bundled plugin's + // .mcp.json (resources/plugins/harness-status/.mcp.json) which is + // loaded by --plugin-dir below; the env vars it interpolates are + // injected onto spawnEnv further down. Skipped (env vars omitted) + // when settings has harnessMcpEnabled=false or the control server + // isn't up — the plugin still loads but ${HARNESS_PORT} would + // expand empty, so the bridge skips itself at startup. const mcpServers: Record< string, { command: string; args: string[]; env: Record } @@ -419,32 +423,27 @@ export class JsonClaudeManager { } } } + const mcpConfig = { mcpServers } + + // The plugin's harness-control entry interpolates these env vars from + // Claude's launch env. Collected here so spawnEnv below can merge them + // in without leaking them into the global process env. + let harnessControlEnv: Record = {} if (this.opts.isHarnessMcpEnabled()) { const controlInfo = this.opts.getControlServer() if (controlInfo) { const scope = this.opts.getCallerScope(sessionId) - const controlEnv: Record = { - ELECTRON_RUN_AS_NODE: '1', - HARNESS_PORT: String(controlInfo.port), - HARNESS_TOKEN: controlInfo.token, - // The bridge keys every control-server call by terminal id — - // for json-claude tabs the tab id IS the session id. - HARNESS_TERMINAL_ID: sessionId, - HARNESS_SESSION_ID: sessionId - } - if (scope) { - controlEnv.HARNESS_WORKTREE_ID = scope.worktreePath - controlEnv.HARNESS_REPO_ROOT = scope.repoRoot - if (scope.isMain) controlEnv.HARNESS_IS_MAIN = '1' - } - mcpServers['harness-control'] = { - command: process.execPath, - args: [this.opts.getControlBridgeScriptPath()], - env: controlEnv - } + harnessControlEnv = buildHarnessControlEnv({ + execPath: process.execPath, + port: controlInfo.port, + token: controlInfo.token, + terminalId: sessionId, + scope: scope + ? { worktreePath: scope.worktreePath, repoRoot: scope.repoRoot, isMain: scope.isMain, terminalId: sessionId } + : null + }) } } - const mcpConfig = { mcpServers } const useSystemClaude = this.opts.getUseSystemClaude() const claudeCommand = this.opts.getClaudeCommand() || 'claude' @@ -468,6 +467,8 @@ export class JsonClaudeManager { 'mcp__harness-permissions__approve', '--mcp-config', JSON.stringify(mcpConfig), + '--plugin-dir', + harnessPluginDir(), ...resumeOrSet ] if (launchSettings.systemPrompt) { @@ -516,7 +517,15 @@ export class JsonClaudeManager { CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1', // So our bundled MCP server can identify its parent session // when it opens the approval socket. - HARNESS_JSON_CLAUDE_SESSION_ID: sessionId + HARNESS_JSON_CLAUDE_SESSION_ID: sessionId, + // Picked up by the bundled plugin's .mcp.json placeholders + // (HARNESS_NODE_EXEC / HARNESS_PORT / HARNESS_TOKEN / + // HARNESS_MCP_TERMINAL_ID / HARNESS_WORKTREE_ID / ...). The + // intentionally separate HARNESS_MCP_TERMINAL_ID var means + // the bridge knows which session it's bound to even though + // HARNESS_TERMINAL_ID stays scrubbed (above) to keep the + // hooks from double-tracking json-mode sessions. + ...harnessControlEnv } let proc: ChildProcessWithoutNullStreams try { diff --git a/src/main/mcp-config.ts b/src/main/mcp-config.ts index 88e9103b..27de832d 100644 --- a/src/main/mcp-config.ts +++ b/src/main/mcp-config.ts @@ -1,82 +1,26 @@ -import { mkdirSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs' -import { join } from 'path' -import { getControlServerInfo } from './control-server' -import type { CallerScope } from './control-server' -import { resolveBundledMcpScript, userDataDir } from './paths' -import { log } from './debug' - -function getConfigDir(): string { - const dir = join(userDataDir(), 'mcp-configs') - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - return dir -} +// Legacy cleanup helper. Harness used to write per-terminal MCP configs +// into userData/mcp-configs/ and pass them via --mcp-config; now the +// bundled Harness plugin (resources/plugins/harness-status/.mcp.json, +// loaded by --plugin-dir) carries the same config and interpolates +// per-spawn env vars at launch time. This function sweeps any stale +// files left over from the prior layout. Safe to remove once all +// upgraded users have booted at least once. -export function getBridgeScriptPath(): string { - return resolveBundledMcpScript('mcp-bridge.js') -} +import { existsSync, readdirSync, unlinkSync } from 'fs' +import { join } from 'path' +import { userDataDir } from './paths' function sanitize(id: string): string { return id.replace(/[^a-zA-Z0-9._-]/g, '_') } -/** - * Write a per-terminal MCP config file pointing Claude Code at the bundled - * harness-control MCP server. Returns the absolute path, or null if the - * control server isn't running. - * - * Injects scope env vars (HARNESS_WORKTREE_ID, HARNESS_REPO_ROOT, - * HARNESS_IS_MAIN, HARNESS_SESSION_ID) so the bridge can advertise - * scope-appropriate tool descriptions at tools/list time. The server side - * still re-resolves scope from the terminal id on every call — the env - * vars are a hint, not the source of truth. - * - * Uses `ELECTRON_RUN_AS_NODE=1` so the Electron binary executes the bridge - * script as a plain Node process — no separate Node install required. - */ -export function writeMcpConfigForTerminal( - terminalId: string, - scope: CallerScope | null -): string | null { - const info = getControlServerInfo() - if (!info) { - log('mcp', 'control server not ready — skipping MCP config write') - return null - } - const configPath = join(getConfigDir(), `${sanitize(terminalId)}.json`) - const env: Record = { - ELECTRON_RUN_AS_NODE: '1', - HARNESS_PORT: String(info.port), - HARNESS_TOKEN: info.token, - HARNESS_TERMINAL_ID: terminalId, - HARNESS_SESSION_ID: terminalId - } - if (scope) { - env.HARNESS_WORKTREE_ID = scope.worktreePath - env.HARNESS_REPO_ROOT = scope.repoRoot - if (scope.isMain) env.HARNESS_IS_MAIN = '1' - } - const config = { - mcpServers: { - 'harness-control': { - command: process.execPath, - args: [getBridgeScriptPath()], - env - } - } - } - try { - writeFileSync(configPath, JSON.stringify(config, null, 2)) - return configPath - } catch (err) { - log('mcp', 'failed to write config', err instanceof Error ? err.message : err) - return null - } -} - -/** Remove mcp config files for terminals not present in `keepIds`. */ +/** Remove mcp config files for terminals not present in `keepIds`. + * Since Harness no longer writes new files here, the practical effect + * is "drain the legacy dir as worktrees churn." */ export function pruneMcpConfigs(keepIds: Set): void { try { - const dir = getConfigDir() + const dir = join(userDataDir(), 'mcp-configs') + if (!existsSync(dir)) return const keep = new Set(Array.from(keepIds).map((id) => `${sanitize(id)}.json`)) for (const file of readdirSync(dir)) { if (!keep.has(file)) { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 58489380..77bda2bd 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -164,14 +164,29 @@ export interface Config { // to the main worktree's copy, and the boot migration doesn't convert // existing regular files. Default is enabled (undefined/true). shareClaudeSettings?: boolean - // User's choice for installing agent status hooks at user scope - // (~/.claude/settings.json, ~/.codex/hooks.json). Persisted so a - // declined user doesn't see the banner again on next launch. + // User's choice for installing Codex status hooks at user scope + // (~/.codex/hooks.json). Claude no longer asks — its hooks ship as a + // bundled plugin loaded via --plugin-dir, so nothing is written to + // user files. Persisted so a declined user doesn't see the banner + // again on next launch. hooksConsent?: 'pending' | 'accepted' | 'declined' - // One-shot migration flag: once true, we've swept all known worktrees' - // per-worktree .claude/settings.local.json + .codex/hooks.json files - // and stripped any legacy Harness entries. Prevents re-running the - // migration on every boot. + // One-shot migration flag: once true, we've swept the legacy Claude + // hook installs (~/.claude/settings.json global entries + per-worktree + // .claude/settings.local.json) and stripped any Harness entries. + // Claude hooks now ship as a plugin; this sweep removes the dead + // copies the user no longer needs. Prevents re-running on every boot. + hooksMigratedToPlugin?: boolean + // One-shot migration flag: once true, we've swept the legacy Codex + // hook installs (~/.codex/hooks.json + per-worktree .codex/hooks.json) + // and stripped any Harness entries. Codex now consumes the same + // bundled plugin as Claude via `codex plugin marketplace add`. Set on + // first boot after the Codex-plugin migration so the strip doesn't + // re-run. + codexPluginMigrated?: boolean + // Deprecated, kept for migration reads: the prior one-shot flag that + // recorded the per-worktree → user-scope sweep. Once Harness has set + // hooksMigratedToPlugin we no longer read this. Safe to remove after + // 2026-08. hooksMigratedToGlobal?: boolean harnessSystemPromptEnabled?: boolean harnessSystemPrompt?: string @@ -298,36 +313,18 @@ export const THEME_APP_BG: Record = { export const DEFAULT_CLAUDE_COMMAND = 'claude' -export const DEFAULT_HARNESS_SYSTEM_PROMPT = `You are running inside Harness, a desktop app that manages multiple Claude Code sessions across git worktrees. You have access to harness-control MCP tools: - -- mcp__harness-control__create_worktree: Create a new worktree with its own Claude session. Always provide a detailed initialPrompt so the new session has full context. -- mcp__harness-control__list_worktrees: List all active worktrees. - -When the user wants to start a new task, fix, or investigation that would benefit from isolation, suggest creating a worktree for it rather than doing everything inline. Each worktree is an independent git branch with its own terminal and Claude session. - -Harness also exposes embedded browser tabs — you can open a browser alongside the terminal and see and drive what's in it via the harness-control browser tools (scoped to this worktree only): - -- create_browser_tab: open a new browser tab in this worktree (optionally navigating to a URL). -- list_browser_tabs, get_tab_url, get_tab_dom, get_tab_console_logs: inspect what's in the tab. -- navigate_tab, back_tab, forward_tab, reload_tab: drive the tab. -- get_tab_clickables: returns a compact JSON snapshot of in-viewport interactive elements (buttons, links, inputs, [role=button|link|tab|menuitem|checkbox|radio|switch|option|combobox|searchbox|textbox], [tabindex], [contenteditable], [onclick]) — including elements inside open shadow roots. Each entry is {role, name, cx, cy, w, h} with the click center already computed. -- click_tab, type_tab, scroll_tab, show_cursor: interact with the page — click at (x, y), type into the focused field, scroll, or just move the visible cursor overlay so the user can see what you're about to do. -- screenshot_tab: visual verification only. Returns JPEG quality 70 by default for context-efficiency; ask for format:'png' only when lossless matters. Screenshot dimensions match the CSS viewport, so coords observed in a screenshot can be passed straight to click_tab. - -Click targeting workflow: **prefer get_tab_clickables → match by role + name → call click_tab(cx, cy) for anything you want to click**. It's far cheaper than a screenshot + vision and far more reliable for real DOM targets. Reserve screenshot_tab for confirming a click had the visual effect you wanted, or for targets without accessible names (canvas/SVG/images). The clickables snapshot is in-viewport only and capped at 500 items — if the target isn't there, scroll_tab first, then re-snapshot. To type into a field: click_tab on it first to focus, then type_tab. type_tab also accepts a \`key\` argument (Enter, Tab, Backspace, ArrowDown, …) for submitting forms or navigating menus. - -Prefer these over blind curl/fetch — or shelling out to \`open \`, which launches the user's default browser outside Harness where you can't see the result — when you need to verify rendered UI, inspect a dev server, debug a page the user is looking at, or confirm your changes actually work in the browser. - -Harness also exposes shell tabs for long-running processes — anything that wouldn't naturally exit within a few seconds (dev servers, watchers, \`tail -f\`, REPL-style tools, long builds). Drive them via the harness-control shell tools (scoped to this worktree only): - -- create_shell: spawn a shell tab, optionally with a command to run (\`zsh -ilc \`). Returns an id — keep it for later reads. -- list_shells: enumerate existing shell tabs (id, label, command, alive). Check here before spawning — don't start a second \`npm run dev\` if one is already running. -- read_shell_output: read a shell's output, optionally with a \`match\` regex + \`context\` lines to scan a long log for errors/warnings without pulling back megabytes. -- kill_shell: terminate the process AND close the tab. For natural exits (process finishes on its own), the tab stays open for inspection — kill_shell is explicit cleanup. - -Prefer these over running long-running commands via Bash — Bash either blocks until the process exits or loses the output stream when backgrounded, whereas a Harness shell tab keeps streaming, stays readable via read_shell_output after the fact, and is visible to the user in the Harness UI. Short one-shots (\`npm test\`, \`tsc --noEmit\`, \`git status\`) still belong on Bash.` - -export const DEFAULT_HARNESS_SYSTEM_PROMPT_MAIN = `You are on the main worktree. This is the primary checkout — avoid making direct changes here unless the user explicitly asks. Instead, use this session to plan, review, and coordinate work across worktrees. When the user describes a task, create a new worktree for it with a thorough initialPrompt that gives the new Claude session all the context it needs to work independently. If you need to run a dev server, watcher, or other long-running process here, use the harness-control shell tools (create_shell / list_shells / read_shell_output / kill_shell) rather than Bash, so the output keeps streaming and stays readable.` +// Foundational identity prompt — keeps the agent aware it's running +// inside Harness without enumerating every tool or workflow. Detailed +// guidance lives in plugin-shipped skills (harness-browser, +// harness-shell, harness-worktree under +// resources/plugins/harness-status/skills/) which Claude Code +// auto-discovers via the same --plugin-dir flag that loads our hooks +// + MCP server. The skills carry the workflow content that used to +// bloat this string; the per-tool MCP descriptions cover the "what +// does each tool do" surface. +export const DEFAULT_HARNESS_SYSTEM_PROMPT = `You are running inside Harness, a desktop app that manages multiple Claude Code sessions across git worktrees. You have harness-control MCP tools for worktree management, embedded browser tabs, and shell tabs for long-running processes. Workflow guidance is available via the harness-browser, harness-shell, and harness-worktree skills — invoke whichever fits the task at hand.` + +export const DEFAULT_HARNESS_SYSTEM_PROMPT_MAIN = `You are on the main worktree. This is the primary checkout — avoid making direct changes here unless the user explicitly asks. Use this session to plan, review, and coordinate work across worktrees; when the user describes a task, prefer spinning off a new worktree (the harness-worktree skill covers how) rather than working inline.` export const DEFAULT_TERMINAL_FONT_FAMILY = "'SF Mono', 'Monaco', 'Menlo', 'Courier New', monospace" diff --git a/src/main/store.ts b/src/main/store.ts index 351d83ba..5355f7d1 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -8,9 +8,8 @@ // each event over the `state:event` IPC channel to all renderer // windows. The renderer applies the SAME reducer to its local mirror, // so renderer state is automatically in sync with no glue code. -// - Internal main-side reactors (e.g. `ActivityDeriver`, -// `installHooksForAcceptedWorktrees`) that observe specific events -// to do side effects. +// - Internal main-side reactors (e.g. `ActivityDeriver`) that observe +// specific events to do side effects. // // The `seq` field exists so a future networked client can resync after // a reconnect — request "everything since seq N" and replay any missed diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d36f64f7..cc35d386 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -564,9 +564,13 @@ const setQuestStep = useCallback((next: QuestStep) => { return () => cancelAnimationFrame(raf) }, [activeWorktreeId, activeTabId]) - // When a worktree becomes active, refresh its PR status if stale. Hooks - // installation and pane initialization both run in main now (see - // installHooksForAcceptedWorktrees + WorktreesFSM in src/main/index.ts). + // When a worktree becomes active, refresh its PR status if stale. + // Pane initialization runs in main via WorktreesFSM. Both Claude and + // Codex now read the same bundled plugin tree: Claude via + // --plugin-dir on every spawn, Codex via the `codex plugin + // marketplace add` + `codex plugin add` registered at consent time + // (see src/main/codex-plugin.ts and the hooks:accept IPC handler in + // src/main/index.ts). useEffect(() => { if (!activeWorktreeId) return if (isPendingId(activeWorktreeId)) return @@ -932,14 +936,11 @@ const setQuestStep = useCallback((next: QuestStep) => { if (repoRoots.length === 0 || previewOnboarding) { const step1Complete = themeChosen const step2Complete = agentChosen - const step3Complete = hooksConsent !== 'pending' - const activeStep: 1 | 2 | 3 | 4 = !step1Complete + const activeStep: 1 | 2 | 3 = !step1Complete ? 1 : !step2Complete ? 2 - : !step3Complete - ? 3 - : 4 + : 3 return (
@@ -1143,31 +1144,12 @@ const setQuestStep = useCallback((next: QuestStep) => { />
)} - - -
-
- {step3Complete ? ( - - ) : ( -
- 3 -
- )} -
-
Install status hooks
-
- Adds a small hook at ~/.claude/settings.json (and the Codex equivalent) so Harness can tell when an agent is{' '} + {agentChosen && defaultAgent === 'codex' && ( +
+
+ Install Harness's status plugin for Codex? Registers one entry in{' '} + ~/.codex/config.toml so + Harness can tell when a Codex agent is{' '} working @@ -1179,39 +1161,37 @@ const setQuestStep = useCallback((next: QuestStep) => { asking for approval - . Only fires for sessions Harness launches — others are untouched. + . +
+
+ +
-
-
- - -
+ )}
{
- 4 + 3
Open a git repository
@@ -1418,31 +1398,10 @@ const setQuestStep = useCallback((next: QuestStep) => {
)} - {/* Hooks consent banner — one-time prompt at first launch. Harness - installs agent status hooks at ~/.claude/settings.json (+ Codex - equivalent). The hook command is gated on $HARNESS_TERMINAL_ID - so sessions spawned outside Harness are unaffected. */} - {hooksConsent === 'pending' && ( -
- - Harness installs status hooks at ~/.claude/settings.json to detect - agent state (waiting, processing, needs approval). They only fire for agents you - launch inside Harness and can be removed at any time from Settings. - - - -
- )} + {/* Codex plugin consent prompt now appears in-tab when the user + actually spawns a Codex agent (see XTerminal.tsx) rather than + as a top-of-app banner — most users never run Codex and the + banner was nagging them about a feature they don't use. */}
{!singleScreenMode && sidebarVisible && ( diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index c2ccab45..2bc205e8 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -237,6 +237,7 @@ export function buildBackend( setClaudeEnvVars: (vars: Record) => req('config:setClaudeEnvVars', vars), setDefaultAgent: (agent: string) => req('config:setDefaultAgent', agent), setCodexCommand: (command: string) => req('config:setCodexCommand', command), + getCodexMarketplaceRoot: () => req('codex:getMarketplaceRoot'), setClaudeModel: (model: string | null) => req('config:setClaudeModel', model), setCodexModel: (model: string | null) => req('config:setCodexModel', model), setCodexEnvVars: (vars: Record) => req('config:setCodexEnvVars', vars), @@ -278,8 +279,6 @@ export function buildBackend( setHarnessSystemPromptMain: (prompt: string) => req('config:setHarnessSystemPromptMain', prompt), setPrReviewPrompt: (prompt: string) => req('config:setPrReviewPrompt', prompt), - prepareMcpForTerminal: (terminalId: string) => - req('mcp:prepareForTerminal', terminalId), onWorktreesExternalCreate: ( callback: (payload: { repoRoot: string; worktree: unknown; initialPrompt?: string }) => void ) => @@ -477,6 +476,7 @@ export function buildBackend( acceptHooks: () => req('hooks:accept'), declineHooks: () => req('hooks:decline'), uninstallHooks: () => req('hooks:uninstall'), + verifyCodexPlugin: () => req('hooks:verify'), browserNavigate: (tabId: string, url: string) => req('browser:navigate', tabId, url), browserBack: (tabId: string) => req('browser:back', tabId), diff --git a/src/renderer/components/Settings.tsx b/src/renderer/components/Settings.tsx index 50d60088..22765875 100644 --- a/src/renderer/components/Settings.tsx +++ b/src/renderer/components/Settings.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from 'react' -import { ArrowLeft, Check, X, Eye, EyeOff, Star, RefreshCw, Download, RotateCw, GitPullRequest, DownloadCloud, Keyboard, RotateCcw, Terminal as TerminalIcon, Palette, BookOpen, Code2, GitBranch, Plus, Trash2, Moon, LifeBuoy, Bug, Lightbulb, FlaskConical, Copy, CopyCheck, ExternalLink, CalendarDays, FileText, FolderOpen, Search, ChevronDown, ChevronRight, SlidersHorizontal } from 'lucide-react' +import { ArrowLeft, Check, X, Eye, EyeOff, Star, RefreshCw, Download, RotateCw, GitPullRequest, DownloadCloud, Keyboard, RotateCcw, Terminal as TerminalIcon, Palette, BookOpen, Code2, GitBranch, Plus, Trash2, Moon, LifeBuoy, Bug, Lightbulb, FlaskConical, Copy, CopyCheck, ExternalLink, CalendarDays, FileText, FolderOpen, Search, ChevronDown, ChevronRight, AlertTriangle, SlidersHorizontal } from 'lucide-react' import { openReportIssue } from './ReportIssueScreen' import { HARNESS_ISSUES_URL, HARNESS_RELEASES_URL, harnessReleaseNotesUrl } from '../../shared/constants' import { useSettings, useUpdater, useRepoConfigs, useHooks } from '../store' @@ -388,6 +388,10 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }: const [codexCommandDraft, setCodexCommandDraft] = useState(codexCommand) useEffect(() => { setCodexCommandDraft(codexCommand) }, [codexCommand]) + const [codexMarketplaceRoot, setCodexMarketplaceRoot] = useState('') + useEffect(() => { + void backend.getCodexMarketplaceRoot().then(setCodexMarketplaceRoot) + }, []) const [codexSaveResult, setCodexSaveResult] = useState<{ ok: boolean; message: string } | null>(null) const [codexEnvRows, setCodexEnvRows] = useState<{ key: string; value: string }[]>(() => Object.entries(codexEnvVars).map(([key, value]) => ({ key, value })) @@ -419,6 +423,22 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }: // Hooks consent — drives the copy in the "Status hooks" card below. const { consent: hooksConsent } = useHooks() + const [codexPluginVerification, setCodexPluginVerification] = + useState(null) + const refreshCodexPluginVerification = useCallback(async () => { + if (hooksConsent !== 'accepted') { + setCodexPluginVerification(null) + return + } + setCodexPluginVerification(await backend.verifyCodexPlugin()) + }, [hooksConsent]) + useEffect(() => { + void refreshCodexPluginVerification() + }, [refreshCodexPluginVerification]) + const handleInstallCodexPlugin = useCallback(async () => { + const result = await backend.acceptHooks() + setCodexPluginVerification(result) + }, []) // WS transport: wsInfo reflects the live server (null when off or not // yet started after enabling — the server only binds at app launch). @@ -1342,7 +1362,7 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }: {/* Main scrollable content */}
-
+
{/* General section */}
{ sectionRefs.current.general = el }} id="general">

General

@@ -1700,44 +1720,6 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }: )}
-

- Status hooks -

-
-

- Harness installs a small hook at{' '} - ~/.claude/settings.json and{' '} - ~/.codex/hooks.json so it can - detect when each agent tab is processing, waiting, or awaiting approval. - The hook only emits when $HARNESS_TERMINAL_ID{' '} - is set — sessions you launch outside Harness are untouched. -

-
- {hooksConsent === 'accepted' ? ( - <> - Installed - - - ) : ( - <> - - {hooksConsent === 'declined' ? 'Declined' : 'Not installed'} - - - - )} -
-
{/* ── Claude subsection ── */} @@ -2061,6 +2043,102 @@ export function Settings({ onClose, onOpenGuide, onOpenMyWeek, initialSection }: {defaultAgent === 'codex' && default} +
+ +

+ Register a plugin to integrate with Codex. This must be done to properly + report agent status in Harness. +

+
+ {`${(codexCommand || 'codex')} plugin marketplace add ${codexMarketplaceRoot || ''}\n${(codexCommand || 'codex')} plugin add harness-status@harness`} +
+
+ {hooksConsent === 'accepted' ? ( + <> + Installed + + + + ) : ( + <> + + Not installed + + + + )} +
+ + {codexPluginVerification && ( +
+ +
    + {( + [ + ['pluginEnabled', 'Plugin enabled in Codex'], + ['hooksPresent', 'Hooks file present in cache'] + ] as const + ).map(([key, label]) => { + const passed = codexPluginVerification[key] + return ( +
  • + {passed ? ( + + ) : ( + + )} + {label} +
  • + ) + })} +
  • + {codexPluginVerification.hooksTrusted ? ( + + ) : ( + + )} + + Hooks trusted by Codex + +
  • +
+ {!codexPluginVerification.hooksTrusted && ( +

+ Codex skips plugin-bundled hooks until you trust them. There's no CLI + command to grant trust — open a new Codex tab (or run{' '} + codex in a terminal), accept + the "Hooks need review" prompt by choosing{' '} + "Trust all and continue", then{' '} + Re-check. +

+ )} + {codexPluginVerification.message && !codexPluginVerification.ok && ( +

+ {codexPluginVerification.message} +

+ )} +
+ )} +
+

diff --git a/src/renderer/components/XTerminal.tsx b/src/renderer/components/XTerminal.tsx index 8e5bed5b..13df470a 100644 --- a/src/renderer/components/XTerminal.tsx +++ b/src/renderer/components/XTerminal.tsx @@ -6,7 +6,7 @@ import { ProgressAddon } from '@xterm/addon-progress' import { SearchAddon } from '@xterm/addon-search' import '@xterm/xterm/css/xterm.css' import type { StateEvent } from '../../shared/state' -import { getClientId, subscribeActiveTransportReconnect, useSettings, useTerminalSession } from '../store' +import { getClientId, subscribeActiveTransportReconnect, useHooks, useSettings, useTerminalSession } from '../store' import { getBackend, useBackend } from '../backend' import { makeFileLinkProvider, @@ -305,6 +305,7 @@ export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionNa initFontCache() const backend = useBackend() const chatPromotionDismissed = useSettings().chatPromotionDismissed + const codexHooksConsent = useHooks().consent // Prime + refresh the worktree file list that validates file-path links. // Shared across this worktree's tabs and rate-limited inside @@ -1063,6 +1064,28 @@ export function XTerminal({ terminalId, cwd, type, agentKind, visible, sessionNa

)} + {!loading && !exited && type === 'agent' && agentKind === 'codex' && codexHooksConsent === 'pending' && ( +
+ + + + + + +
+ )} {exited && type === 'agent' && onRestartAgent && (
diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 0ead0aa5..c379b947 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -16,6 +16,9 @@ export type { SessionCostSummary, ClaudeAuthInfo, SubscriptionTier } import type { AddRepoResult } from '../shared/repo-pick' export type { AddRepoResult } +import type { CodexPluginVerification } from '../shared/codex-plugin' +export type { CodexPluginVerification } + /** Per-kind dirtiness flags for a worktree. `git` reflects * uncommitted changes; `scratchpad` reflects a non-empty scratchpad * note. The delete-worktree flow surfaces each kind separately so the @@ -344,13 +347,13 @@ export interface ElectronAPI { setHarnessSystemPrompt(prompt: string): Promise setHarnessSystemPromptMain(prompt: string): Promise setPrReviewPrompt(prompt: string): Promise - prepareMcpForTerminal(terminalId: string): Promise onWorktreesExternalCreate( callback: (payload: { repoRoot: string; worktree: Worktree; initialPrompt?: string }) => void ): () => void setClaudeEnvVars(vars: Record): Promise setDefaultAgent(agent: string): Promise setCodexCommand(command: string): Promise + getCodexMarketplaceRoot(): Promise setClaudeModel(model: string | null): Promise setCodexModel(model: string | null): Promise setCodexEnvVars(vars: Record): Promise @@ -489,9 +492,10 @@ export interface ElectronAPI { onUiScaleDown(callback: () => void): () => void onUiScaleReset(callback: () => void): () => void - acceptHooks(): Promise + acceptHooks(): Promise declineHooks(): Promise uninstallHooks(): Promise + verifyCodexPlugin(): Promise browserNavigate(tabId: string, url: string): Promise browserBack(tabId: string): Promise diff --git a/src/shared/codex-plugin.ts b/src/shared/codex-plugin.ts new file mode 100644 index 00000000..d480425f --- /dev/null +++ b/src/shared/codex-plugin.ts @@ -0,0 +1,26 @@ +// Cross-process type for the Codex plugin install / probe result. +// Defined in shared/ so both the main-process producer +// (src/main/codex-plugin.ts) and the renderer-process consumer +// (Settings card, banner) can reference one interface without coupling +// the renderer to main internals. + +export interface CodexPluginVerification { + /** True iff every assertion below also passed. */ + ok: boolean + /** `codex plugin list --marketplace harness` shows the plugin as + * `installed, enabled`. */ + pluginEnabled: boolean + /** The plugin cache materialized hooks/hooks.json. */ + hooksPresent: boolean + /** Codex has persisted trust hashes (`trusted_hash = "sha256:…"` in + * ~/.codex/config.toml `[hooks.state]`) covering every event in the + * plugin's hooks.json. Plugin install does NOT auto-trust — Codex + * skips untrusted plugin hooks until the user accepts the TUI + * "Hooks need review / Trust all and continue" prompt on first + * launch. There's no CLI command to grant trust, so when this is + * false the user must open a Codex session interactively. */ + hooksTrusted: boolean + /** Free-form note for surfacing in the UI when one of the assertions + * fails (e.g. raw stderr from a failed `codex plugin add`). */ + message?: string +} diff --git a/src/shared/state/hooks.ts b/src/shared/state/hooks.ts index e75836d9..5c6478d9 100644 --- a/src/shared/state/hooks.ts +++ b/src/shared/state/hooks.ts @@ -1,11 +1,13 @@ export type HooksConsent = 'pending' | 'accepted' | 'declined' export interface HooksState { - /** User's choice about installing Harness's status hooks at user scope - * (~/.claude/settings.json, ~/.codex/hooks.json). The hook command is - * env-gated on $HARNESS_TERMINAL_ID, so sessions outside Harness aren't - * affected. Main seeds this on boot from config.hooksConsent; the accept - * / decline / uninstall IPC handlers keep the persisted copy in sync. */ + /** User's choice about installing Codex status hooks at user scope + * (~/.codex/hooks.json). Claude no longer requires consent — its + * hooks ship as a plugin loaded via --plugin-dir on every spawn, so + * no user file is touched. The Codex hook command is env-gated on + * $HARNESS_TERMINAL_ID, so sessions outside Harness aren't affected. + * Main seeds this on boot from config.hooksConsent; the accept / + * decline / uninstall IPC handlers keep the persisted copy in sync. */ consent: HooksConsent }