Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A Go library for authoring coding-agent hooks once and running them everywhere:
Claude Code, Cursor (IDE + CLI + cloud), OpenAI Codex, Gemini CLI, OpenCode,
Kimi Code, and GitHub Copilot CLI.
OpenClaw, Kimi Code, GitHub Copilot CLI, and Copilot Chat in VS Code.

The core promise: **one clear interface, zero data-fidelity loss**. The library
owns the per-provider glue, hacks, and workarounds so consumers don't have to.
Expand Down Expand Up @@ -44,6 +44,20 @@ a crashed hook from denying every tool call. `prompt.submitted` gets an empty
capability set because Copilot discards command-hook output for
`userPromptSubmitted`.)

(**Copilot Chat in VS Code** is a ninth provider and a *second Copilot
dialect*, not a surface of the first: its wire shape is Claude Code's —
snake_case stdin, PascalCase event names, `hookSpecificOutput` response — so
both codecs delegate to the Claude ones and the distinct constant exists for
the capability row and the config renderer. It fires 8 of the CLI's 14 events,
parses `matcher` values and then ignores them, and injects no environment
marker into the hook child, so detection is flag-only. Two placements diverge
from Claude Code and are fixed up in `encodeVSCode`: `decision`/`reason` ride
*inside* `hookSpecificOutput` on `Stop`/`SubagentStop`, and `SubagentStart`
honors `additionalContext`, which Claude Code does not. The capability row was
read out of the extension source rather than the reference, which contradicts
itself on both the nesting and the timeout key — see quirk registry entries
#42–#49.)

Consequence: the unified contract should be **Claude-shaped semantics with
typed extensions**, not a lowest-common-denominator invention. Three of five
providers natively converge on it; Cursor half-converges; only OpenCode needs a
Expand Down Expand Up @@ -610,6 +624,19 @@ Per-target rendering encodes the workaround knowledge:
`command` is emitted, never `bash`/`powershell`: Copilot copies `command`
into both when absent. No `failClosed` knob either — Copilot fixes the
posture per event (`preToolUse` fail-closed, everything else fail-open).
- **VS Code Copilot Chat**: `agenthooks-vscode.json` with PascalCase event
keys, at `<~/.copilot>/hooks/` (user) or `.github/hooks/` (project); no
plugin scope. Both directories are globbed by the Copilot CLI *as well*, so
the distinct basename is what keeps this file from colliding with the CLI's
— and, being neither `settings.json` nor `hooks.json`, it is whole-file
owned rather than merged. Four omissions, each preventing a silent failure:
no `matcher` (VS Code parses matchers and ignores them, so `--filter` on the
argv is the only real enforcement), no `version` (absent from every VS Code
example; an unknown key is a schema-validation risk for zero benefit), no
`bash`/`powershell` (VS Code's split is `windows`/`linux`/`osx`, and the
rendered argv is valid in every shell), and **both** `timeout` and
`timeoutSec` at the same value, because the reference table names one and a
usage example on the same doc set names the other.

Matchers: `ToolMatcher` compiles to the provider dialect where expressible
(Claude regex/exact-list rules incl. the hyphen/comma version gates, Gemini
Expand Down Expand Up @@ -690,7 +717,8 @@ upstream reference. Seeded from provider research and production observation:

The table below is the initially seeded set; `quirks.go` is the authoritative
registry and has grown past it (entries #21+, including the OpenClaw rows
#34–#37).
#34–#37, the Copilot CLI dialect rows #38–#41, and the VS Code Copilot Chat
rows #42–#49).

| # | Quirk | Mitigation |
|---|---|---|
Expand Down
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

<p align="center">
<h1 align="center"><b>agenthooks</b></h1>
<p align="center">Author coding-agent hooks once in Go; run them on Claude Code, Cursor, OpenAI Codex, Gemini CLI, OpenCode, Kimi Code, OpenClaw, and GitHub Copilot CLI.</p>
<p align="center">Author coding-agent hooks once in Go; run them on Claude Code, Cursor, OpenAI Codex, Gemini CLI, OpenCode, Kimi Code, OpenClaw, GitHub Copilot CLI, and Copilot Chat in VS Code.</p>
<p align="center">
<!-- Go Doc Badge -->
<a href="https://pkg.go.dev/github.com/speakeasy-api/agenthooks"><img alt="Go Doc" src="https://img.shields.io/badge/godoc-reference-blue.svg?style=for-the-badge"></a>
Expand Down Expand Up @@ -210,9 +210,22 @@ config, and an absent one already means match-all.
ambiguous or unrecoverable matches stay empty.
Disable with `WithoutMCPResolution()` (everything) or
`WithoutMCPListFallback()` (provider CLI probes).
- Copilot hooks are **CLI-only** (the IDE surfaces fire nothing) and the
dialect needs the most repair: most payloads omit their own event name, so
the codec reconstructs it from the payload shape — the shapes are disjoint,
- Copilot ships **two dialects behind one name**: the CLI's camelCase wire
(`copilot`) and Copilot Chat in VS Code, which speaks the Claude-shaped
dialect (`vscode-copilot`). Both runtimes glob the same two hook
directories, so the `vscode-copilot` file serves both — its PascalCase
event keys are the CLI's Claude-compat mode, and the CLI's own `COPILOT_*`
env demotes the session to `copilot` before decode, so each runtime gets
its own capability row and response schema (VS Code nested, the CLI flat).
Install `vscode-copilot` for both runtimes (8 events) or `copilot` for 12
mapped CLI events; the CLI's two other native events currently decode only as
`KindOther` and cannot be registered by generated configs. Installing both
targets double-fires in the CLI. Cross-runtime
registration is verified against Copilot CLI 1.0.81 for the five events a
headless turn can drive; `SubagentStart`, `SubagentStop` and `PreCompact`
are unmeasured there.
- The CLI dialect needs the most repair: most payloads omit their own event
name, so the codec reconstructs it from the shape — the shapes are disjoint,
so this is exact. `toolArgs` is a JSON-encoded *string* on
`pre`/`postToolUse` while `permissionRequest` ships a plain object in
`toolInput`; both normalize to an object. No tool-call id ships at all, so
Expand Down
4 changes: 4 additions & 0 deletions agenthooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,10 @@ func (r *Runner) Run(ctx context.Context, args []string, stdin io.Reader, stdout
}

provider, conf := detectProvider(inv, payload)
// The generated vscode-copilot file is loaded by the Copilot CLI too; when
// the CLI is the runtime, its own env demotes the flag so the session gets
// the CLI's capability row and flat response schema.
provider = demoteVSCodeToCLI(provider)
if provider == "" {
r.logger.Error("agenthooks: cannot detect provider; emitting neutral no-op", "payload_bytes", len(payload))
_, _ = fmt.Fprint(stdout, "{}")
Expand Down
2 changes: 2 additions & 0 deletions agenthookstest/agenthookstest.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ func FixtureDir(p agenthooks.Provider) string {
return "openclaw"
case agenthooks.ProviderKimi:
return "kimi"
case agenthooks.ProviderVSCodeCopilot:
return "vscode"
}
return string(p)
}
Expand Down
1 change: 1 addition & 0 deletions agenthookstest/fixtures/claude/subagent_start.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"session_id":"sess-claude-1","transcript_path":"/tmp/claude/transcript.jsonl","cwd":"/work/repo","hook_event_name":"SubagentStart","agent_id":"agent-9","agent_type":"Explore"}
1 change: 1 addition & 0 deletions agenthookstest/fixtures/vscode/pre_tool_use.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"timestamp":"2026-08-29T17:29:22.255Z","hook_event_name":"PreToolUse","session_id":"a1b2c3d4-e5f6-4a7b-8c9d-000000000001","transcript_path":"/work/vscode/workspaceStorage/0123456789abcdef0123456789abcdef/GitHub.copilot-chat/transcripts/a1b2c3d4-e5f6-4a7b-8c9d-000000000001.jsonl","tool_name":"read_file","tool_input":{"filePath":"/work/repo/README.md","startLine":1,"endLine":80},"tool_use_id":"call_01ABCDEFGHIJKLMNOPQRST__vscode-1700000000000","cwd":"/work/repo"}
1 change: 1 addition & 0 deletions agenthookstest/fixtures/vscode/session_start.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"timestamp":"2026-08-29T17:29:20.031Z","hook_event_name":"SessionStart","session_id":"a1b2c3d4-e5f6-4a7b-8c9d-000000000001","transcript_path":"/work/vscode/workspaceStorage/0123456789abcdef0123456789abcdef/GitHub.copilot-chat/transcripts/a1b2c3d4-e5f6-4a7b-8c9d-000000000001.jsonl","source":"new","model":"auto","cwd":"/work/repo"}
1 change: 1 addition & 0 deletions agenthookstest/fixtures/vscode/stop.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"timestamp":"2026-08-29T17:29:39.042Z","hook_event_name":"Stop","session_id":"a1b2c3d4-e5f6-4a7b-8c9d-000000000001","transcript_path":"/work/vscode/workspaceStorage/0123456789abcdef0123456789abcdef/GitHub.copilot-chat/transcripts/a1b2c3d4-e5f6-4a7b-8c9d-000000000001.jsonl","stop_hook_active":false,"cwd":"/work/repo"}
27 changes: 27 additions & 0 deletions capability.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,33 @@ var capMatrix = map[Provider]map[EventKind]CapSet{
KindSubagentStop: caps(CapContinueAgent),
KindSessionStart: caps(CapAddContext),
},
ProviderVSCodeCopilot: {
// Verified against the extension source, not the docs, which contradict
// themselves on placement. PreToolUse is the only event whose decision
// rides hookSpecificOutput; PostToolUse and UserPromptSubmit block via
// TOP-LEVEL decision/reason; Stop/SubagentStop block via NESTED
// decision/reason (encodeVSCode moves them). SessionStart/SubagentStart
// are processed with ignoreErrors and drop stopReason silently, so no
// CapStopAgent there. No CapReplaceOutput anywhere: updatedToolOutput is
// a Claude extension VS Code does not read. No CapAsk outside ToolPre.
// PostToolUse CapAddContext is contract-level: VS Code 1.135 accepts it
// and appends it to the tool result, but its panel path starts that async
// work without awaiting it (quirk #49). Keep the capability so the same
// documented payload works once the upstream race is fixed.
// permission.request, session.end, tool.error and notification are absent:
// VS Code never fires them.
//
// SubagentStart and CompactPre are observe-only in the public runner,
// so their upstream output channels are not capabilities here. Stop and
// SubagentStop cannot advertise CapStopAgent because StopDecision has no
// operation that sets it.
KindToolPre: caps(CapDeny, CapAsk, CapAllow, CapUpdateInput, CapAddContext, CapSystemMessage, CapStopAgent),
KindToolPost: caps(CapAddContext, CapSystemMessage, CapStopAgent),
KindPromptSubmitted: caps(CapDeny, CapAddContext, CapSystemMessage, CapStopAgent),
KindSessionStart: caps(CapAddContext, CapSystemMessage),
KindStop: caps(CapContinueAgent, CapSystemMessage),
KindSubagentStop: caps(CapContinueAgent, CapSystemMessage),
},
ProviderKimi: {
// Only UserPromptSubmit, PreToolUse and Stop are blockable; JSON
// output understands deny|allow only — no ask, no updatedInput, no
Expand Down
4 changes: 4 additions & 0 deletions codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ func decodePayload(p Provider, v Variant, conf DetectionConfidence, now time.Tim
return decodeKimi(v, conf, now, payload)
case ProviderCopilot:
return decodeCopilot(v, conf, now, payload)
case ProviderVSCodeCopilot:
return decodeClaudeAs(ProviderVSCodeCopilot, v, conf, now, payload)
}
return nil, fmt.Errorf("agenthooks: unknown provider %q", p)
}
Expand Down Expand Up @@ -105,6 +107,8 @@ func encodeDecision(typed any, d decisionCore) (wireResponse, error) {
return encodeKimi(base, d)
case ProviderCopilot:
return encodeCopilot(base, d)
case ProviderVSCodeCopilot:
return encodeVSCode(base, d)
}
return wireResponse{}, fmt.Errorf("agenthooks: unknown provider %q", base.Provider)
}
Expand Down
16 changes: 14 additions & 2 deletions codec_claudecode.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ type claudeIn struct {
}

func decodeClaude(v Variant, conf DetectionConfidence, now time.Time, payload []byte) (any, error) {
return decodeClaudeProvider(ProviderClaudeCode, v, conf, now, payload)
}

func decodeClaudeProvider(p Provider, v Variant, conf DetectionConfidence, now time.Time, payload []byte) (any, error) {
var in claudeIn
if err := json.Unmarshal(payload, &in); err != nil {
return nil, err
Expand All @@ -65,7 +69,7 @@ func decodeClaude(v Variant, conf DetectionConfidence, now time.Time, payload []
kind = KindOther
}
base := Event{
Provider: ProviderClaudeCode,
Provider: p,
Variant: v,
NativeName: in.HookEventName,
Kind: kind,
Expand All @@ -86,12 +90,20 @@ func decodeClaude(v Variant, conf DetectionConfidence, now time.Time, payload []
base.Agent = &AgentInfo{ID: in.AgentID, Type: in.AgentType}
}
typed := buildClaudeShaped(base, &in)
if event, ok := typed.(*ToolPostEvent); ok {
if event, ok := typed.(*ToolPostEvent); ok && p == ProviderClaudeCode {
backfillClaudeSkillOutput(event)
}
return typed, nil
}

// decodeClaudeAs decodes a Claude-shaped payload under a different provider
// label. VS Code Copilot Chat and the Copilot CLI's PascalCase compat mode
// both ship the Claude wire shape verbatim; only the response schema differs,
// and that is selected downstream by Provider.
func decodeClaudeAs(p Provider, v Variant, conf DetectionConfidence, now time.Time, payload []byte) (any, error) {
return decodeClaudeProvider(p, v, conf, now, payload)
}

Comment thread
svadrutk marked this conversation as resolved.
Outdated
// buildClaudeShaped constructs typed events from the Claude-shaped wire form.
// Codex deliberately ships the same shapes, so its decoder reuses this.
func buildClaudeShaped(base Event, in *claudeIn) any {
Expand Down
14 changes: 14 additions & 0 deletions codec_copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,20 @@ func decodeCopilot(v Variant, conf DetectionConfidence, now time.Time, payload [
if err := json.Unmarshal(payload, &in); err != nil {
return nil, err
}
// Claude-shaped fallthrough. A --provider=copilot registration can receive
// the snake_case Claude wire shape from two directions: the CLI running the
// PascalCase compat file this library installs for VS Code, and VS Code
// discovering a camelCase CLI file (both runtimes glob both hook
// directories). copilotEventName has no camelCase shape to reconstruct from
// there, so without this the event lands on KindOther with the tool fields
// lost. The discriminator is an explicit event name with no camelCase
// sessionId — every genuine Copilot payload keys the session on sessionId,
// including the one native event (notification) that also ships
// hook_event_name. The label stays ProviderCopilot so encodeCopilot still
// answers in the CLI's flat schema.
if in.HookEventName != "" && in.SessionID == "" {
return decodeClaudeAs(ProviderCopilot, v, conf, now, payload)
}
native := copilotEventName(&in)
kind, ok := copilotKinds[native]
if !ok {
Expand Down
106 changes: 106 additions & 0 deletions codec_copilot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,3 +375,109 @@ func TestCopilotDetection(t *testing.T) {
t.Errorf("shape detection = %q", p)
}
}

// A --provider=copilot registration receives the Claude-shaped snake_case
// payload from two directions — the CLI running the PascalCase compat file
// this library installs for VS Code, and VS Code discovering a camelCase CLI
// file, because both runtimes glob both hook directories. copilotEventName has
// no camelCase shape to reconstruct from there, so before the fallthrough
// every one of these landed on KindOther with the tool fields empty: hooks
// that look installed and healthy while reporting nothing useful.
func TestCopilotClaudeShapedFallthrough(t *testing.T) {
for _, tc := range []struct {
fixture, native string
kind EventKind
}{
{"claude/pre_tool_use.json", "PreToolUse", KindToolPre},
{"claude/user_prompt_submit.json", "UserPromptSubmit", KindPromptSubmitted},
{"claude/post_tool_use.json", "PostToolUse", KindToolPost},
{"claude/session_start.json", "SessionStart", KindSessionStart},
{"claude/stop.json", "Stop", KindStop},
} {
typed, err := decodeCopilot(VariantUnknown, DetectionConfig, testNow, fixture(t, tc.fixture))
if err != nil {
t.Fatalf("%s: %v", tc.fixture, err)
}
ev := eventOf(typed)
if ev.NativeName != tc.native || ev.Kind != tc.kind {
t.Errorf("%s decoded as native=%q kind=%q, want %q/%q", tc.fixture, ev.NativeName, ev.Kind, tc.native, tc.kind)
}
// The label must stay ProviderCopilot: it is what selects the CLI's
// flat response schema downstream. decodeClaude hardcodes
// claude-code, so the relabel in decodeClaudeAs is load-bearing.
if ev.Provider != ProviderCopilot {
t.Errorf("%s provider = %q, want %q", tc.fixture, ev.Provider, ProviderCopilot)
}
if ev.Session.ID != "sess-claude-1" {
t.Errorf("%s session id = %q", tc.fixture, ev.Session.ID)
}
}

// The bug this fixes, stated as the assertion: tool arguments survive.
typed, err := decodeCopilot(VariantUnknown, DetectionConfig, testNow, fixture(t, "claude/pre_tool_use.json"))
if err != nil {
t.Fatal(err)
}
pre, ok := typed.(*ToolPreEvent)
if !ok {
t.Fatalf("decoded %T, want *ToolPreEvent", typed)
}
if pre.Tool.Name != "Bash" || pre.Tool.Canonical != ToolShell {
t.Errorf("tool = %+v; the whole point of the fallthrough is that these are populated", pre.Tool)
}

// The camelCase corpus must be untouched. copilot/notification.json is the
// trap: it is the one native Copilot event that ships hook_event_name, so
// only the sessionId half of the discriminator keeps it on this path.
for name, want := range map[string]string{
"copilot/notification.json": "notification",
"copilot/pre_tool_use.json": "preToolUse",
"copilot/agent_stop.json": "agentStop",
"copilot/session_start.json": "sessionStart",
} {
typed, err := decodeCopilot(VariantUnknown, DetectionConfig, testNow, fixture(t, name))
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if ev := eventOf(typed); ev.NativeName != want {
t.Errorf("%s decoded as native=%q, want %q; the fallthrough stole a camelCase payload", name, ev.NativeName, want)
}
}
}

// End to end for the shared file: ONE installed agenthooks-vscode.json, run by
// both runtimes. The Copilot CLI's own env demotes the --provider flag, and
// everything downstream follows from the provider constant — so the same
// PascalCase input produces the CLI's FLAT body here and VS Code's nested one
// without it. A deny answered in the wrong placement is accepted and ignored
// by either runtime, which is why this is asserted rather than reasoned about.
func TestCopilotPascalCaseSharedFile(t *testing.T) {
vscodeArgs := []string{"agenthooks", "run", "--provider=vscode-copilot"}
denier := func() *Runner {
r := quietRunner()
r.OnToolPre(func(ctx context.Context, e *ToolPreEvent) (ToolPreDecision, error) {
return Deny("blocked by policy"), nil
})
return r
}

t.Setenv("COPILOT_CLI", "1")
out, code := runWith(t, denier(), vscodeArgs, fixture(t, "claude/pre_tool_use.json"))
if out != `{"permissionDecision":"deny","permissionDecisionReason":"blocked by policy"}` || code != 0 {
t.Errorf("CLI session = %q (exit %d), want copilot's flat deny at exit 0", out, code)
}

t.Setenv("COPILOT_CLI", "")
out, code = runWith(t, denier(), vscodeArgs, fixture(t, "claude/pre_tool_use.json"))
var body struct {
HSO struct {
PermissionDecision string `json:"permissionDecision"`
} `json:"hookSpecificOutput"`
}
if err := json.Unmarshal([]byte(out), &body); err != nil {
t.Fatalf("VS Code session stdout %q: %v", out, err)
}
if body.HSO.PermissionDecision != "deny" || code != 0 {
t.Errorf("VS Code session = %q (exit %d), want a nested deny at exit 0", out, code)
}
}
Loading
Loading