Skip to content
Open
Show file tree
Hide file tree
Changes from all 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-cli`) 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-cli` 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-cli` 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
4 changes: 4 additions & 0 deletions agenthookstest/agenthookstest.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ func FixtureDir(p agenthooks.Provider) string {
return "openclaw"
case agenthooks.ProviderKimi:
return "kimi"
case agenthooks.ProviderCopilotCLI:
return "copilot"
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"}
2 changes: 1 addition & 1 deletion agenthookstest/fixtures/copilot/post_tool_use.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"sessionId":"sess-copilot-1","timestamp":1786820437772,"cwd":"/work/repo","toolName":"bash","toolArgs":"{\"command\":\"echo hello-from-gram\",\"description\":\"Echo hello-from-gram\"}","toolResult":{"resultType":"success","textResultForLlm":"hello-from-gram\n<shellId: 0 completed with exit code 0>"}}
{"sessionId":"sess-copilot-1","timestamp":1786820437772,"cwd":"/work/repo","toolName":"bash","toolArgs":{"command":"echo hello-from-gram","description":"Echo hello-from-gram"},"toolResult":{"resultType":"success","textResultForLlm":"hello-from-gram\n<shellId: 0 completed with exit code 0>"}}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"sessionId":"sess-copilot-1","timestamp":1786820662904,"cwd":"/work/repo","toolName":"view","toolArgs":"{\"path\":\"/work/repo/no-such-file-here.txt\"}","error":"Path does not exist"}
{"sessionId":"sess-copilot-1","timestamp":1786820662904,"cwd":"/work/repo","toolName":"view","toolArgs":{"path":"/work/repo/no-such-file-here.txt"},"error":"Path does not exist"}
2 changes: 1 addition & 1 deletion agenthookstest/fixtures/copilot/pre_tool_use.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"sessionId":"sess-copilot-1","timestamp":1786820437717,"cwd":"/work/repo","toolName":"bash","toolArgs":"{\"command\":\"echo hello-from-gram\",\"description\":\"Echo hello-from-gram\"}"}
{"sessionId":"sess-copilot-1","timestamp":1786820437717,"cwd":"/work/repo","toolName":"bash","toolArgs":{"command":"echo hello-from-gram","description":"Echo hello-from-gram"}}
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"}
29 changes: 28 additions & 1 deletion capability.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ var capMatrix = map[Provider]map[EventKind]CapSet{
KindToolPre: caps(CapDeny, CapAsk, CapUpdateInput),
KindPromptSubmitted: caps(CapDeny),
},
ProviderCopilot: {
ProviderCopilotCLI: {
// preToolUse and permissionRequest are the only decision-capable
// events: deny was observed enforced end to end, and it fires even
// under --allow-all/--yolo. prompt.submitted is deliberately an empty
Expand All @@ -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
8 changes: 6 additions & 2 deletions codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ func decodePayload(p Provider, v Variant, conf DetectionConfidence, now time.Tim
return decodeOpenClawLine(v, conf, now, payload)
case ProviderKimi:
return decodeKimi(v, conf, now, payload)
case ProviderCopilot:
case ProviderCopilotCLI:
return decodeCopilot(v, conf, now, payload)
case ProviderVSCodeCopilot:
return decodeVSCode(v, conf, now, payload)
}
return nil, fmt.Errorf("agenthooks: unknown provider %q", p)
}
Expand Down Expand Up @@ -103,8 +105,10 @@ func encodeDecision(typed any, d decisionCore) (wireResponse, error) {
return wireResponse{Stdout: out}, nil
case ProviderKimi:
return encodeKimi(base, d)
case ProviderCopilot:
case ProviderCopilotCLI:
return encodeCopilot(base, d)
case ProviderVSCodeCopilot:
return encodeVSCode(base, d)
}
return wireResponse{}, fmt.Errorf("agenthooks: unknown provider %q", base.Provider)
}
Expand Down
Loading
Loading