g1t/apps/web/public/install/hook.mjs
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Record your own agent's sessions automatically | 1 | #!/usr/bin/env node |
| 2 | // g1t's Claude Code hook: records what your agent does onto the pull | |
| 3 | // request you are working on, so its session sits beside the code. | |
| 4 | // | |
| 5 | // Claude Code runs this for UserPromptSubmit, PostToolUse and Stop, with | |
| 6 | // the event as JSON on stdin. It works out the pull request from the | |
| 7 | // working copy's git remote: a g1t fork (g1t.sh/pulls/<id>) or a branch | |
| 8 | // of a g1t repository with an open pull request. Anywhere else it does | |
| 9 | // nothing. It never fails the agent's turn. | |
| 10 | // | |
| 11 | // Installed by `curl -fsSL https://g1t.sh/install/claude.sh | sh`. | |
| 12 | ||
| 13 | import { execFileSync } from "node:child_process"; | |
| 14 | import { existsSync, readFileSync, writeFileSync } from "node:fs"; | |
| 15 | import { homedir } from "node:os"; | |
| 16 | import { join } from "node:path"; | |
| 17 | ||
| 18 | const HOME = join(homedir(), ".g1t"); | |
| 19 | const CONFIG = join(HOME, "config.json"); | |
| 20 | const CACHE = join(HOME, "pulls.json"); | |
| 21 | /** The longest any one entry is sent. */ | |
| 22 | const MAX_TEXT = 4000; | |
| 23 | ||
| 24 | function read(path, fallback) { | |
| 25 | try { | |
| 26 | return JSON.parse(readFileSync(path, "utf8")); | |
| 27 | } catch { | |
| 28 | return fallback; | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | function git(cwd, ...args) { | |
| 33 | try { | |
| 34 | return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); | |
| 35 | } catch { | |
| 36 | return ""; | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | function clip(text) { | |
| 41 | const value = typeof text === "string" ? text : JSON.stringify(text ?? ""); | |
| 42 | return value.length > MAX_TEXT ? `${value.slice(0, MAX_TEXT)}…` : value; | |
| 43 | } | |
| 44 | ||
| 45 | /** What a tool call did, in one line where possible. */ | |
| 46 | function describeCall(name, input = {}) { | |
| 47 | if (input.command) return input.command; | |
| 48 | if (input.file_path) return input.file_path; | |
| 49 | if (input.pattern) return `${input.pattern}${input.path ? ` in ${input.path}` : ""}`; | |
| 50 | if (input.url) return input.url; | |
| 51 | return clip(input); | |
| 52 | } | |
| 53 | ||
| 54 | /** The pull request this working copy belongs to, if any. Cached per copy. */ | |
| 55 | async function pullFor(cwd, config) { | |
| 56 | const remote = git(cwd, "remote", "get-url", "origin"); | |
| 57 | const branch = git(cwd, "branch", "--show-current"); | |
| 58 | if (!remote.includes("g1t.sh/")) return null; | |
| 59 | const key = `${remote}#${branch}`; | |
| 60 | const cache = read(CACHE, {}); | |
| 61 | if (key in cache) return cache[key]; | |
| 62 | const headers = { authorization: `Bearer ${config.token}` }; | |
| 63 | let found = null; | |
| 64 | const fork = /g1t\.sh\/pulls\/([a-z0-9_]+?)(?:\.git)?$/.exec(remote); | |
| 65 | const repo = /g1t\.sh\/([a-z0-9-]+)\/([a-z0-9._-]+?)(?:\.git)?$/i.exec(remote); | |
| 66 | if (fork) { | |
| 67 | const response = await fetch(`${config.api}/pulls/${fork[1]}`, { headers }); | |
| 68 | if (response.ok) { | |
| 69 | const pull = await response.json(); | |
| 70 | found = { repo: `${pull.repo.namespace}/${pull.repo.name}`, number: pull.number }; | |
| 71 | } | |
| 72 | } else if (repo && branch) { | |
| 73 | const response = await fetch(`${config.api}/repos/${repo[1]}/${repo[2]}/pulls?state=open`, { headers }); | |
| 74 | if (response.ok) { | |
| 75 | const pulls = await response.json(); | |
| 76 | const pull = (Array.isArray(pulls) ? pulls : []).find((p) => p.branch === branch); | |
| 77 | if (pull) found = { repo: `${repo[1]}/${repo[2]}`, number: pull.number }; | |
| 78 | } | |
| 79 | } | |
| 80 | cache[key] = found; | |
| 81 | try { | |
| 82 | writeFileSync(CACHE, JSON.stringify(cache)); | |
| 83 | } catch {} | |
| 84 | return found; | |
| 85 | } | |
| 86 | ||
| 87 | /** The agent's last words this turn, from its transcript. */ | |
| 88 | function lastMessage(transcriptPath) { | |
| 89 | if (!transcriptPath || !existsSync(transcriptPath)) return null; | |
| 90 | const lines = readFileSync(transcriptPath, "utf8").trim().split("\n").reverse(); | |
| 91 | for (const line of lines) { | |
| 92 | try { | |
| 93 | const entry = JSON.parse(line); | |
| 94 | if (entry.type !== "assistant") continue; | |
| 95 | const content = entry.message?.content; | |
| 96 | const text = Array.isArray(content) | |
| 97 | ? content.filter((part) => part.type === "text").map((part) => part.text).join("\n") | |
| 98 | : typeof content === "string" | |
| 99 | ? content | |
| 100 | : ""; | |
| 101 | if (text.trim()) return text.trim(); | |
| 102 | } catch {} | |
| 103 | } | |
| 104 | return null; | |
| 105 | } | |
| 106 | ||
| 107 | async function main() { | |
| 108 | const config = read(CONFIG, null); | |
| 109 | if (!config?.token) return; | |
| 110 | let raw = ""; | |
| 111 | for await (const chunk of process.stdin) raw += chunk; | |
| 112 | const event = JSON.parse(raw || "{}"); | |
| 113 | const pull = await pullFor(event.cwd ?? process.cwd(), config); | |
| 114 | if (!pull) return; | |
| 115 | ||
| 116 | const entries = []; | |
| 117 | switch (event.hook_event_name) { | |
| 118 | case "UserPromptSubmit": | |
| 119 | if (event.prompt) entries.push({ kind: "prompt", text: clip(event.prompt) }); | |
| 120 | break; | |
| 121 | case "PostToolUse": | |
| 122 | entries.push({ kind: "tool_call", tool: event.tool_name, text: clip(describeCall(event.tool_name, event.tool_input)) }); | |
| 123 | if (event.tool_response != null) { | |
| 124 | entries.push({ kind: "tool_result", tool: "result", text: clip(event.tool_response) }); | |
| 125 | } | |
| 126 | break; | |
| 127 | case "Stop": { | |
| 128 | const message = lastMessage(event.transcript_path); | |
| 129 | if (message) entries.push({ kind: "message", text: clip(message) }); | |
| 130 | break; | |
| 131 | } | |
| 132 | } | |
| 133 | if (entries.length === 0) return; | |
| 134 | await fetch(`${config.api}/repos/${pull.repo}/pulls/${pull.number}/session`, { | |
| 135 | method: "POST", | |
| 136 | headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" }, | |
| 137 | body: JSON.stringify({ entries }), | |
| 138 | }); | |
| 139 | } | |
| 140 | ||
| 141 | main().catch(() => {}).finally(() => process.exit(0)); |