TypeBack

Tutorial · Updated

VS Code agent hooks: capture every Copilot agent edit

How VS Code's agent hooks (preview) report Copilot agent mode edits, the file they live in, what the PostToolUse payload does and doesn't contain, and how to recover the changed hunks.

VS Code can run a shell command at fixed points in a Copilot agent mode session: when a session starts, when you submit a prompt, before and after each tool call, and when the agent stops. The command gets a JSON payload on stdin. As of September 2026 the feature is in preview, so names and fields may change.

This tutorial sets up a hook that fires on every agent edit, shows what the payload holds and what it leaves out, and turns it into the changed hunks with git. The references are the official VS Code hooks guide and the hooks reference.

Where hooks live

The setting chat.useHooks turns hooks on, and it is on by default. VS Code then reads hook files from these places:

  • Workspace: any .json file in .github/hooks/.
  • User: ~/.copilot/hooks/*.json.
  • Custom agents and plugins: hooks in an .agent.md front matter, or a plugin's hooks.json.
  • Claude Code's files: .claude/settings.json and .claude/settings.local.json, only when chat.useClaudeHooks is on. It is off by default.

chat.hookFilesLocations adds or disables locations. For a hook that points at a script on your own machine, use a file of its own in .github/hooks/, such as log-edits.json, and keep it out of git.

A minimal config

The format is flat. Each event name maps straight to a list of commands, with no matcher and no nested groups. This file runs one script after every tool call and at each turn's start and end:

.github/hooks/log-edits.json
1{
2"hooks": {
3"PostToolUse": [
4{ "type": "command", "command": "node /absolute/path/to/on-edit.js", "timeout": 5 }
5],
6"UserPromptSubmit": [
7{ "type": "command", "command": "node /absolute/path/to/on-edit.js", "timeout": 5 }
8],
9"Stop": [
10{ "type": "command", "command": "node /absolute/path/to/on-edit.js", "timeout": 5 }
11]
12}
13}
  • PostToolUse runs after every tool the agent calls: reads, searches, terminal commands and edits. With no matcher, the script has to pick out the edits itself.
  • UserPromptSubmit marks a turn's start and Stop its end.
  • timeout is in seconds. The reference gives 30 as the default.
  • A command can also have windows, linux and osx overrides, and an env object for environment variables.

For a worked example, this is the file TypeBack's installer writes on a Mac with node on the PATH:

.github/hooks/typeback.json (TypeBack)
1{
2"hooks": {
3"PostToolUse": [
4{ "type": "command", "command": "node '/Users/you/.typeback/bin/typeback-hook.js'", "timeout": 5 }
5],
6"UserPromptSubmit": [
7{
8"type": "command",
9"command": "node '/Users/you/.typeback/bin/typeback-hook.js' --agent copilot-agent",
10"timeout": 5
11}
12],
13"Stop": [
14{
15"type": "command",
16"command": "node '/Users/you/.typeback/bin/typeback-hook.js' --agent copilot-agent",
17"timeout": 5
18}
19]
20}
21}

What the script receives

Every event sends timestamp, cwd, session_id, hook_event_name and transcript_path. The reference marks all but the first and the event name as optional. PostToolUse adds tool_name, tool_input, tool_use_id and tool_response. UserPromptSubmit adds prompt. Stop adds stop_hook_active, which is true when the agent is already continuing because a Stop hook blocked it.

The reference does not list the tool names or their inputs. It tells you to read the agent debug logs for them. As of September 2026, Copilot agent edits come through two tools:

  • editFiles, with tool_input.files: a list of paths.
  • createFile, with tool_input.path.

An edit payload, trimmed to the fields that matter:

1{
2"timestamp": "2026-09-27T10:00:00.000Z",
3"cwd": "/Users/you/my-app",
4"session_id": "…",
5"hook_event_name": "PostToolUse",
6"tool_name": "editFiles",
7"tool_input": { "files": ["src/math.ts"] }
8}

That is all you get: which files changed. There is no before text, no after text and no line numbers. Paths may be relative, so resolve each one against cwd.

Recover the changed hunks with git

The file on disk is already the after state, so git can supply the before state. For a tracked file, git diff HEAD -- file gives the hunks. For a new, untracked file, the whole file is the change. Outside a repository there is nothing to diff against, so the whole file is all you have.

This script does that and appends one JSON line per file to ~/agent-edits.jsonl. It logs turn events too, and ignores every other tool. It uses execFileSync with an argument list, so a file name is never parsed by a shell.

on-edit.js
1// on-edit.js: log what each Copilot agent edit changed, as a git diff.
2// Never write to stdout, and always exit 0.
3const { execFileSync } = require('node:child_process');
4const fs = require('node:fs');
5const os = require('node:os');
6const path = require('node:path');
7 
8const LOG = path.join(os.homedir(), 'agent-edits.jsonl');
9const EDIT_TOOLS = new Set(['editFiles', 'createFile']);
10 
11const git = (cwd, args) =>
12execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] });
13 
14function change(cwd, file) {
15try {
16git(cwd, ['ls-files', '--error-unmatch', '--', file]);
17return { diff: git(cwd, ['diff', '--no-color', 'HEAD', '--', file]) };
18} catch {
19// New, untracked, or outside a repository: the whole file is the change.
20const st = fs.statSync(file, { throwIfNoEntry: false });
21return st && st.isFile() && st.size < 1024 * 1024 ? { content: fs.readFileSync(file, 'utf8') } : {};
22}
23}
24 
25let input = '';
26process.stdin.setEncoding('utf8');
27process.stdin.on('data', (chunk) => {
28input += chunk;
29});
30process.stdin.on('end', () => {
31try {
32const p = JSON.parse(input);
33const base = { at: p.timestamp, event: p.hook_event_name, session: p.session_id, tool: p.tool_name };
34if (p.hook_event_name !== 'PostToolUse') {
35fs.appendFileSync(LOG, JSON.stringify(base) + '\n');
36} else if (EDIT_TOOLS.has(p.tool_name)) {
37const cwd = p.cwd || process.cwd();
38const t = p.tool_input || {};
39const files = Array.isArray(t.files) ? t.files.slice() : [];
40if (typeof t.path === 'string') files.push(t.path);
41for (const f of files) {
42const file = path.resolve(cwd, f);
43fs.appendFileSync(LOG, JSON.stringify({ ...base, file, ...change(cwd, file) }) + '\n');
44}
45}
46} catch {
47// A hook that logs must never break the agent.
48}
49process.exit(0);
50});

It is CommonJS. Keep it outside any folder whose package.json says "type": "module", or name it on-edit.cjs. Try it on a repository with an uncommitted change:

cd /path/to/your/repo
echo '{"timestamp":"2026-09-27T10:00:00.000Z","cwd":"'"$PWD"'","hook_event_name":"PostToolUse","tool_name":"editFiles","tool_input":{"files":["README.md"]}}' \
| node /absolute/path/to/on-edit.js
echo "exit code: $?"
tail -n 1 ~/agent-edits.jsonl

One limit: git diff HEAD shows everything since the last commit. That includes your own edits and the agent's earlier edits to the same file. To get only this edit, keep the last diff you saw for each file and compare, or drop hunks you have already logged.

Gotchas

It is a preview

The hook events, fields and tool names can change between VS Code releases. The docs warn against copying a tool name from another agent: Claude Code's Edit is not Copilot's editFiles. Check the debug logs after an update.

Every hook is a process start the agent waits for

As of September 2026, VS Code hooks have no background option like Claude Code's async. Each hook runs in the foreground, and with no matcher, PostToolUse runs after every tool call, not only edits. Make the script return fast for the tools it does not care about, and set a short timeout.

Stay off stdout, and exit 0

On exit code 0, VS Code reads stdout, and JSON there can add context or control what the agent does next. Exit code 2 makes stderr a blocking error that goes to the model. Any other code shows a warning. A logging hook should print nothing and exit 0.

Claude Code's hooks can fire for Copilot

With chat.useClaudeHooks on, VS Code also runs the hooks in .claude/settings*.json, and it ignores their matchers, so every command for the event runs. A Claude Code edit hook then fires after every Copilot tool call, and Claude Code's turn hooks fire for Copilot turns. To tell the two apart, check for timestamp: every VS Code payload has it, and Claude Code's never do. The Claude Code hooks tutorial covers that side.

Windows runs PowerShell

On Windows, VS Code runs the command with powershell.exe -Command. A command that starts with a quoted path is read as a string there, not run, so the hook never starts. Prefix it with the call operator (& 'C:\Program Files\nodejs\node.exe' ...), or give a separate windows command:

1{
2"type": "command",
3"command": "node /absolute/path/to/on-edit.js",
4"windows": "node C:\\tools\\on-edit.js",
5"timeout": 5
6}

Environment variables belong in the env field, not in a shell prefix such as FOO=1 node ..., which PowerShell does not understand.

Guard what you read

The payload names a path. It does not promise a regular file of a sane size. Reading a FIFO or a device would block the hook until the timeout. Check with stat first, as the script does, and skip anything large.

Keep the hook file out of git

.github/hooks/ is also where a team commits hooks meant for everyone. A file there with your absolute paths would run a script your teammates do not have, after every tool call. Keep your own file out with .git/info/exclude. It works like .gitignore, but it lives in your clone and is never committed.

# from the repository root
echo '.github/hooks/log-edits.json' >> "$(git rev-parse --git-path info/exclude)"
git check-ignore -v .github/hooks/log-edits.json

git rev-parse --git-path info/exclude finds the right file in a linked worktree too. Paths in it are relative to the repository root. git check-ignore -v prints the rule that matched.

What TypeBack does with the edit