Skip to content

Commit 7541332

Browse files
committed
refactor: bundle status hooks + MCP bridge as a Claude plugin
Move Claude status hooks and the harness-control MCP bridge into a bundled Claude Code plugin at resources/plugins/harness-status/. Every Claude spawn (xterm + json-mode) now passes --plugin-dir, so there's no user-file install to consent to and no merge against user-authored hooks. The plugin tree's .mcp.json + servers/mcp-bridge.js replace the previous per-terminal mcp.json materialization under userData. The plugin tree also doubles as a Codex plugin marketplace (.agents/plugins/marketplace.json wraps the same harness-status dir). Codex 0.133+ reads the .claude-plugin/plugin.json manifest, hooks.json, .mcp.json and skills/ from the same tree without modification. codex-plugin.ts scaffolds the boot-time install path (marketplace add + plugin add) but is not yet wired into the Codex consent flow — that landing is a follow-up. Other changes: - One-shot boot migration strips legacy Harness entries from ~/.claude/settings.json + per-worktree .claude/settings.local.json (gated by config.hooksMigratedToPlugin). - Hooks consent slice is now Codex-only; Claude needs no consent. - New harness-control-env.ts builds the MCP server env (port, token, per-terminal scope) so both spawn paths set it consistently. - harnessPluginMarketplaceRoot() resolves the marketplace root for the forthcoming Codex install path.
1 parent 7ab8b9d commit 7541332

27 files changed

Lines changed: 725 additions & 382 deletions

CLAUDE.md

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ src/
9595
│ ├── worktrees.ts # list, repoRoots, pending FSM entries
9696
│ ├── terminals.ts # statuses, pendingTools, shellActivity, panes, lastActive
9797
│ ├── onboarding.ts # quest step
98-
│ ├── hooks.ts # consent + justInstalled
98+
│ ├── hooks.ts # Codex hooks consent (Claude rides on --plugin-dir)
9999
│ ├── updater.ts # status (checking/available/downloading/…)
100100
│ ├── repo-configs.ts # byRepo: per-repo .harness.json contents
101101
│ └── *.test.ts # vitest reducer tests, one per slice
@@ -195,10 +195,6 @@ Some main-side modules subscribe to the store and react to events:
195195
`terminals/*` and `prs/*` events, computes per-worktree effective
196196
state, debounces `lastActive` updates, dedups `recordActivity` calls
197197
to `activity.ts`.
198-
- **`installHooksForAcceptedWorktrees`** — small subscriber in
199-
`main/index.ts` that listens for `worktrees/listChanged` and
200-
`hooks/consentChanged`, installs hooks into any new worktree if
201-
consent is `'accepted'`.
202198

203199
Construction order in `main/index.ts` matters: `PanesFSM` is constructed
204200
**before** `WorktreesFSM` because the latter's `onWorktreeCreated`
@@ -288,11 +284,33 @@ event type if you're trying to find where something happens.
288284
## How status detection works
289285

290286
The reliable status (processing / waiting / needs-approval) comes from
291-
**Claude Code hooks** that we install into each worktree's
292-
`.claude/settings.local.json`. The hooks write a status JSON to
293-
`/tmp/harness-status/<terminal-id>.json` and the main process watches that
294-
directory via `fs.watch`. The hook script uses `$CLAUDE_HARNESS_ID` env var
295-
which the PtyManager sets when spawning each terminal.
287+
**Claude Code hooks** that emit one NDJSON line per event to
288+
`/tmp/harness-status/<terminal-id>.ndjson`. The main process watches that
289+
directory via `fs.watch` and tails the file (see `src/main/hooks.ts`).
290+
The hook command env-gates on `$HARNESS_TERMINAL_ID` (set by PtyManager
291+
when it spawns each tab), so sessions outside Harness no-op cleanly.
292+
293+
**How the hooks get loaded:**
294+
295+
- **Claude** — Harness ships a local Claude Code plugin under
296+
`resources/plugins/harness-status/` (manifest at
297+
`.claude-plugin/plugin.json`, hooks at `hooks/hooks.json`). Every
298+
Claude spawn — both the xterm path and json-mode — passes
299+
`--plugin-dir <bundled-path>`. Nothing is written to
300+
`~/.claude/settings.json`; no consent is required. The plugin is
301+
resolved via `harnessPluginDir()` in `src/main/claude-plugin.ts`
302+
(mirrors the `process.resourcesPath` pattern used by `mcp-bridge.js`).
303+
A drift-detection test in `src/main/claude-plugin.test.ts` asserts the
304+
shipped `hooks.json` matches what `makeHookCommand()` would generate
305+
today so the two can't silently diverge.
306+
- **Codex** — still installed at user scope (`~/.codex/hooks.json`),
307+
gated by the consent banner / Settings card. Codex has no plugin
308+
system; users have to opt in once. The `hooks/consent` slice +
309+
`hooks:accept|decline|uninstall` IPC handlers exist for this path
310+
only. A one-shot boot migration (gated by
311+
`config.hooksMigratedToPlugin`) strips any legacy Harness entries from
312+
the old global Claude install + per-worktree
313+
`.claude/settings.local.json` files.
296314

297315
## How performance debugging works
298316

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,13 @@
6363
"**/node_modules/@anthropic-ai/claude-code-*/**/*"
6464
],
6565
"extraResources": [
66-
{
67-
"from": "resources/mcp-bridge.js",
68-
"to": "mcp-bridge.js"
69-
},
7066
{
7167
"from": "resources/permission-prompt-mcp.js",
7268
"to": "permission-prompt-mcp.js"
69+
},
70+
{
71+
"from": "resources/plugins",
72+
"to": "plugins"
7373
}
7474
],
7575
"mac": {

resources/mcp-bridge.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { dirname, join } from 'path'
66

77
const __filename = fileURLToPath(import.meta.url)
88
const __dirname = dirname(__filename)
9-
const BRIDGE = join(__dirname, 'mcp-bridge.js')
9+
const BRIDGE = join(__dirname, 'plugins', 'harness-status', 'servers', 'mcp-bridge.js')
1010

1111
function startStub(handler) {
1212
return new Promise((resolve) => {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "harness",
3+
"version": "1.0.0",
4+
"plugins": [
5+
{
6+
"name": "harness-status",
7+
"source": "./harness-status"
8+
}
9+
]
10+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "harness-status",
3+
"version": "1.0.0",
4+
"description": "Harness status detection hooks. Generated; see scripts/generate-plugin-hooks.mjs."
5+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"mcpServers": {
3+
"harness-control": {
4+
"command": "${HARNESS_NODE_EXEC}",
5+
"args": ["${CLAUDE_PLUGIN_ROOT}/servers/mcp-bridge.js"],
6+
"env": {
7+
"ELECTRON_RUN_AS_NODE": "1",
8+
"HARNESS_PORT": "${HARNESS_PORT}",
9+
"HARNESS_TOKEN": "${HARNESS_TOKEN:-}",
10+
"HARNESS_TERMINAL_ID": "${HARNESS_MCP_TERMINAL_ID}",
11+
"HARNESS_SESSION_ID": "${HARNESS_MCP_TERMINAL_ID}",
12+
"HARNESS_WORKTREE_ID": "${HARNESS_WORKTREE_ID:-}",
13+
"HARNESS_REPO_ROOT": "${HARNESS_REPO_ROOT:-}",
14+
"HARNESS_IS_MAIN": "${HARNESS_IS_MAIN:-}"
15+
}
16+
}
17+
}
18+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"hooks": {
3+
"UserPromptSubmit": [
4+
{
5+
"hooks": [
6+
{
7+
"type": "command",
8+
"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\"'",
9+
"timeout": 5
10+
}
11+
]
12+
}
13+
],
14+
"PreToolUse": [
15+
{
16+
"hooks": [
17+
{
18+
"type": "command",
19+
"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\"'",
20+
"timeout": 5
21+
}
22+
]
23+
}
24+
],
25+
"PostToolUse": [
26+
{
27+
"hooks": [
28+
{
29+
"type": "command",
30+
"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\"'",
31+
"timeout": 5
32+
}
33+
]
34+
}
35+
],
36+
"Stop": [
37+
{
38+
"hooks": [
39+
{
40+
"type": "command",
41+
"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\"'",
42+
"timeout": 5
43+
}
44+
]
45+
}
46+
],
47+
"Notification": [
48+
{
49+
"hooks": [
50+
{
51+
"type": "command",
52+
"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\"'",
53+
"timeout": 5
54+
}
55+
]
56+
}
57+
]
58+
}
59+
}

resources/mcp-bridge.js renamed to resources/plugins/harness-status/servers/mcp-bridge.js

File renamed without changes.

src/main/agents/claude.test.ts

Lines changed: 71 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ vi.mock('../hooks', () => ({
2929

3030
import { homedir } from 'os'
3131
import { join } from 'path'
32-
import { buildSpawnArgs, hooksInstalled, installHooks, hookEvents, uninstallHooks } from './claude'
32+
import { buildSpawnArgs, hookEvents, stripGlobalHooks } from './claude'
3333

3434
const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json')
3535

@@ -62,63 +62,42 @@ describe('buildSpawnArgs', () => {
6262
expect(result).toContain('--append-system-prompt')
6363
expect(result).toContain("'\\''")
6464
})
65-
})
6665

67-
describe('hook install / dedup', () => {
68-
it('hooksInstalled() recognizes normalized entries with no _marker field', () => {
69-
// Simulate what Claude Code leaves behind after normalizing settings.json:
70-
// the _marker and _version sidecar fields are stripped, only the
71-
// {type, command, timeout} triple remains.
72-
const settings = {
73-
hooks: {
74-
UserPromptSubmit: [
75-
{
76-
hooks: [
77-
{
78-
type: 'command',
79-
command:
80-
"bash -c 'd=/tmp/harness-status; printf hi >> \"$d/$h.ndjson\"'",
81-
timeout: 5
82-
}
83-
]
84-
}
85-
]
86-
}
87-
}
88-
fsState.files.set(SETTINGS_PATH, JSON.stringify(settings))
89-
expect(hooksInstalled()).toBe(true)
66+
it('passes --plugin-dir pointing at the bundled Harness status plugin', () => {
67+
const result = buildSpawnArgs({ ...base })
68+
expect(result).toContain('--plugin-dir')
69+
expect(result).toContain('resources/plugins/harness-status')
9070
})
71+
})
9172

92-
it('hooksInstalled() returns false when only user-authored hooks exist', () => {
93-
const settings = {
94-
hooks: {
95-
UserPromptSubmit: [
96-
{
97-
hooks: [{ type: 'command', command: 'echo user hook', timeout: 5 }]
98-
}
99-
]
100-
}
101-
}
102-
fsState.files.set(SETTINGS_PATH, JSON.stringify(settings))
103-
expect(hooksInstalled()).toBe(false)
73+
describe('stripGlobalHooks (legacy migration)', () => {
74+
it('returns false when settings.json has no Harness entries', () => {
75+
fsState.files.set(
76+
SETTINGS_PATH,
77+
JSON.stringify({
78+
hooks: {
79+
UserPromptSubmit: [
80+
{
81+
hooks: [{ type: 'command', command: 'echo user hook', timeout: 5 }]
82+
}
83+
]
84+
}
85+
})
86+
)
87+
expect(stripGlobalHooks()).toBe(false)
88+
const after = JSON.parse(fsState.files.get(SETTINGS_PATH) as string)
89+
expect(after.hooks.UserPromptSubmit).toHaveLength(1)
10490
})
10591

106-
it('installHooks() called twice yields exactly one harness entry per event', () => {
107-
installHooks()
108-
installHooks()
109-
const settings = JSON.parse(fsState.files.get(SETTINGS_PATH) as string)
110-
for (const event of hookEvents) {
111-
const entries = settings.hooks[event]
112-
expect(entries).toHaveLength(1)
113-
expect(entries[0].hooks[0].command).toContain('/tmp/harness-status')
114-
}
92+
it('returns false when settings.json does not exist', () => {
93+
expect(stripGlobalHooks()).toBe(false)
11594
})
11695

117-
it('installHooks() collapses pre-existing duplicates left by buggy passes', () => {
118-
// Three duplicate harness entries per event, all in normalized form
119-
// (no _marker / _version). This is the exact shape the user reports
120-
// after several buggy install passes.
121-
const dupEntry = {
96+
it('removes legacy Harness entries while preserving user-authored hooks', () => {
97+
const userHook = {
98+
hooks: [{ type: 'command', command: 'echo user hook', timeout: 10 }]
99+
}
100+
const harnessHook = {
122101
hooks: [
123102
{
124103
type: 'command',
@@ -128,66 +107,62 @@ describe('hook install / dedup', () => {
128107
}
129108
]
130109
}
131-
const settings: { hooks: Record<string, unknown[]> } = { hooks: {} }
132-
for (const event of hookEvents) {
133-
settings.hooks[event] = [dupEntry, dupEntry, dupEntry]
134-
}
135-
fsState.files.set(SETTINGS_PATH, JSON.stringify(settings))
136-
137-
installHooks()
138-
139-
const after = JSON.parse(fsState.files.get(SETTINGS_PATH) as string)
140-
for (const event of hookEvents) {
141-
expect(after.hooks[event]).toHaveLength(1)
142-
}
143-
})
144-
145-
it('installHooks() preserves user-authored hooks (commands not pointing at /tmp/harness-status)', () => {
146-
const userHook = {
147-
hooks: [{ type: 'command', command: 'echo user hook', timeout: 10 }]
148-
}
149110
fsState.files.set(
150111
SETTINGS_PATH,
151112
JSON.stringify({
152113
hooks: {
153-
UserPromptSubmit: [userHook],
154-
PreToolUse: [userHook]
114+
UserPromptSubmit: [userHook, harnessHook],
115+
PreToolUse: [harnessHook]
155116
},
156117
unrelatedKey: 'preserve-me'
157118
})
158119
)
159120

160-
installHooks()
161-
121+
expect(stripGlobalHooks()).toBe(true)
162122
const after = JSON.parse(fsState.files.get(SETTINGS_PATH) as string)
163123
expect(after.unrelatedKey).toBe('preserve-me')
164-
// User hook still there + one harness entry appended
165-
expect(after.hooks.UserPromptSubmit).toContainEqual(userHook)
166-
expect(after.hooks.PreToolUse).toContainEqual(userHook)
167-
for (const event of hookEvents) {
168-
const harnessEntries = (after.hooks[event] as Array<{ hooks: { command: string }[] }>).filter(
169-
(e) => e.hooks.some((h) => h.command.includes('/tmp/harness-status'))
170-
)
171-
expect(harnessEntries).toHaveLength(1)
172-
}
124+
// User hook survives; harness entry stripped.
125+
expect(after.hooks.UserPromptSubmit).toEqual([userHook])
126+
// Event with only harness entry → key removed entirely.
127+
expect(after.hooks.PreToolUse).toBeUndefined()
173128
})
174129

175-
it('uninstallHooks() removes harness entries but preserves user-authored hooks', () => {
176-
installHooks()
177-
// Add a user-authored hook alongside
178-
const after = JSON.parse(fsState.files.get(SETTINGS_PATH) as string)
179-
after.hooks.UserPromptSubmit.push({
180-
hooks: [{ type: 'command', command: 'echo user hook' }]
181-
})
182-
fsState.files.set(SETTINGS_PATH, JSON.stringify(after))
130+
it('drops the hooks object entirely when no events remain', () => {
131+
fsState.files.set(
132+
SETTINGS_PATH,
133+
JSON.stringify({
134+
hooks: {
135+
PreToolUse: [
136+
{
137+
hooks: [
138+
{
139+
type: 'command',
140+
command: 'bash -c \'d=/tmp/harness-status; echo x\'',
141+
timeout: 5
142+
}
143+
]
144+
}
145+
]
146+
},
147+
otherKey: 'keep'
148+
})
149+
)
183150

184-
uninstallHooks()
151+
expect(stripGlobalHooks()).toBe(true)
152+
const after = JSON.parse(fsState.files.get(SETTINGS_PATH) as string)
153+
expect(after.hooks).toBeUndefined()
154+
expect(after.otherKey).toBe('keep')
155+
})
156+
})
185157

186-
const final = JSON.parse(fsState.files.get(SETTINGS_PATH) as string)
187-
expect(final.hooks?.UserPromptSubmit).toEqual([
188-
{ hooks: [{ type: 'command', command: 'echo user hook' }] }
158+
describe('hookEvents', () => {
159+
it('exports the events the bundled plugin must register', () => {
160+
expect(hookEvents).toEqual([
161+
'UserPromptSubmit',
162+
'PreToolUse',
163+
'PostToolUse',
164+
'Stop',
165+
'Notification'
189166
])
190-
// Other events had no user hooks, so they should be gone entirely.
191-
expect(final.hooks?.PreToolUse).toBeUndefined()
192167
})
193168
})

0 commit comments

Comments
 (0)