Record your own agent's sessions automatically
curl -fsSL https://g1t.sh/install/claude.sh | sh signs in through the browser and adds a Claude Code hook that records prompts, tool calls and the agent's closing account onto the pull request a working copy belongs to, found from its git remote: a g1t fork or a branch with an open pull request. GET /pulls/{id} tells a tool where a pull request lives from its fork.
9 files+348−20/9 viewed
| 347 | 347 | ("GET", "/openapi.json") => return Response::from_json(&openapi::document()), | |
| 348 | 348 | ("POST", "/device/code") => return device_code(&mut request, &services).await, | |
| 349 | 349 | ("POST", "/device/token") => return device_token(&mut request, &services).await, | |
| 350 | + | // Where a pull request lives, for a tool that knows only its fork. | |
| 351 | + | ("GET", path) if path.starts_with("/pulls/") && !path[7..].contains('/') => { | |
| 352 | + | let located: Outcome<Value> = g1t_kit::call( | |
| 353 | + | &services.work, | |
| 354 | + | "locate_pull", | |
| 355 | + | &json!({ "id": &path[7..], "viewer": viewer }), | |
| 356 | + | ) | |
| 357 | + | .await?; | |
| 358 | + | return match located { | |
| 359 | + | Outcome::Ok(value) => Response::from_json(&value), | |
| 360 | + | Outcome::Fail(refused) => failure(&refused), | |
| 361 | + | }; | |
| 362 | + | } | |
| 350 | 363 | ("POST", path) if path.starts_with("/queue/") => { | |
| 351 | 364 | let entry_id = path.trim_start_matches("/queue/").to_owned(); | |
| 352 | 365 | return report_queue(&mut request, &services, &entry_id).await; |
| 32 | 32 | Ask Claude Code to list the open issues on a repository, or to work on one, | |
| 33 | 33 | and it will use the tools below. | |
| 34 | 34 | ||
| 35 | + | ### Recording sessions automatically | |
| 36 | + | ||
| 37 | + | An agent can record its own session with `record_session`, but it has to | |
| 38 | + | remember to. To have every session recorded without asking, install g1t's | |
| 39 | + | hook: | |
| 40 | + | ||
| 41 | + | ```sh | |
| 42 | + | curl -fsSL https://g1t.sh/install/claude.sh | sh | |
| 43 | + | ``` | |
| 44 | + | ||
| 45 | + | It signs you in through the browser, keeps the token in `~/.g1t`, and adds | |
| 46 | + | a hook to `~/.claude/settings.json`. From then on, whenever Claude Code | |
| 47 | + | works in a g1t pull request's working copy, your prompts, its tool calls | |
| 48 | + | and its closing account are recorded onto that pull request's session as | |
| 49 | + | they happen, where people and why-blame can see them. It recognises a fork | |
| 50 | + | (`g1t.sh/pulls/<id>`) and a branch of a g1t repository with an open pull | |
| 51 | + | request; anywhere else it does nothing. It needs Node 18 or later, which | |
| 52 | + | Claude Code runs on. | |
| 53 | + | ||
| 54 | + | To stop recording, remove the `node ~/.g1t/hook.mjs` entries from | |
| 55 | + | `~/.claude/settings.json`. | |
| 56 | + | ||
| 35 | 57 | ## How an agent works on an issue | |
| 36 | 58 | ||
| 37 | 59 | 1. `get_issue` to read the description and acceptance checks, and to see |
| 212 | 212 | </h2> | |
| 213 | 213 | <p className="mt-4 max-w-md leading-7 text-muted"> | |
| 214 | 214 | Add g1t to Claude Code and it can read the plan, take an issue, open a pull request | |
| 215 | − | with a fork to push to, see what the others are doing and record its session. g1t's | |
| 216 | − | own agents use the same tools. | |
| 215 | + | with a fork to push to and see what the others are doing. Install the hook and its | |
| 216 | + | session is recorded onto the pull request as it works. g1t's own agents use the | |
| 217 | + | same tools. | |
| 217 | 218 | </p> | |
| 218 | 219 | <div className="mt-6"> | |
| 219 | 220 | <ButtonLink to="https://docs.g1t.sh/guides/bring-your-own-agent/" variant="quiet"> | |
| 224 | 225 | </div> | |
| 225 | 226 | <div className="space-y-3"> | |
| 226 | 227 | <CopyLine prompt text="claude mcp add --transport http g1t https://mcp.g1t.sh" /> | |
| 228 | + | <CopyLine prompt text="curl -fsSL https://g1t.sh/install/claude.sh | sh" /> | |
| 227 | 229 | <CopyLine prompt text="git clone https://g1t.sh/syntaqx/g1t.git" /> | |
| 228 | 230 | <CopyLine prompt text="curl https://api.g1t.sh/repos/syntaqx/g1t/queue" /> | |
| 229 | 231 | </div> |
| 1 | + | #!/bin/sh | |
| 2 | + | # Connects Claude Code on this machine to g1t, so the sessions of your own | |
| 3 | + | # agent are recorded onto the pull requests they belong to: | |
| 4 | + | # | |
| 5 | + | # curl -fsSL https://g1t.sh/install/claude.sh | sh | |
| 6 | + | # | |
| 7 | + | # It downloads two small scripts into ~/.g1t, signs you in through the | |
| 8 | + | # browser, and adds a hook to ~/.claude/settings.json. Needs Node 18 or | |
| 9 | + | # later, which Claude Code itself runs on. | |
| 10 | + | set -eu | |
| 11 | + | ||
| 12 | + | BASE="${G1T_SITE:-https://g1t.sh}" | |
| 13 | + | ||
| 14 | + | if ! command -v node >/dev/null 2>&1; then | |
| 15 | + | echo "g1t: Node 18 or later is needed (Claude Code runs on it too)." >&2 | |
| 16 | + | exit 1 | |
| 17 | + | fi | |
| 18 | + | ||
| 19 | + | mkdir -p "$HOME/.g1t" | |
| 20 | + | curl -fsSL "$BASE/install/hook.mjs" -o "$HOME/.g1t/hook.mjs" | |
| 21 | + | curl -fsSL "$BASE/install/setup.mjs" -o "$HOME/.g1t/setup.mjs" | |
| 22 | + | # Read from the terminal, not from the pipe this script arrived on. | |
| 23 | + | node "$HOME/.g1t/setup.mjs" < /dev/tty |
| 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)); |
| 1 | + | #!/usr/bin/env node | |
| 2 | + | // Sets up g1t for Claude Code on this machine: signs in through the | |
| 3 | + | // browser (nothing to paste), keeps the token in ~/.g1t, and adds g1t's | |
| 4 | + | // hook to ~/.claude/settings.json so sessions are recorded onto the pull | |
| 5 | + | // requests they belong to. Run by https://g1t.sh/install/claude.sh. | |
| 6 | + | ||
| 7 | + | import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; | |
| 8 | + | import { homedir } from "node:os"; | |
| 9 | + | import { join } from "node:path"; | |
| 10 | + | ||
| 11 | + | const API = process.env.G1T_API ?? "https://api.g1t.sh"; | |
| 12 | + | const HOME = join(homedir(), ".g1t"); | |
| 13 | + | const CONFIG = join(HOME, "config.json"); | |
| 14 | + | const SETTINGS = join(homedir(), ".claude", "settings.json"); | |
| 15 | + | const HOOK = `node "${join(HOME, "hook.mjs")}"`; | |
| 16 | + | ||
| 17 | + | const sleep = (ms) => new Promise((done) => setTimeout(done, ms)); | |
| 18 | + | ||
| 19 | + | async function signIn() { | |
| 20 | + | if (existsSync(CONFIG)) { | |
| 21 | + | try { | |
| 22 | + | const config = JSON.parse(readFileSync(CONFIG, "utf8")); | |
| 23 | + | const me = await fetch(`${API}/user`, { headers: { authorization: `Bearer ${config.token}` } }); | |
| 24 | + | if (me.ok) { | |
| 25 | + | console.log(`Already signed in as ${(await me.json()).username}.`); | |
| 26 | + | return; | |
| 27 | + | } | |
| 28 | + | } catch {} | |
| 29 | + | } | |
| 30 | + | const started = await ( | |
| 31 | + | await fetch(`${API}/device/code`, { | |
| 32 | + | method: "POST", | |
| 33 | + | headers: { "content-type": "application/json" }, | |
| 34 | + | body: JSON.stringify({ client_name: "Claude Code on this machine" }), | |
| 35 | + | }) | |
| 36 | + | ).json(); | |
| 37 | + | console.log(`\nOpen ${started.verification_uri_complete}`); | |
| 38 | + | console.log(`and check that the code there is ${started.user_code}.\n`); | |
| 39 | + | const deadline = Date.now() + started.expires_in * 1000; | |
| 40 | + | while (Date.now() < deadline) { | |
| 41 | + | await sleep(Math.max(started.interval, 2) * 1000); | |
| 42 | + | const claim = await ( | |
| 43 | + | await fetch(`${API}/device/token`, { | |
| 44 | + | method: "POST", | |
| 45 | + | headers: { "content-type": "application/json" }, | |
| 46 | + | body: JSON.stringify({ device_code: started.device_code }), | |
| 47 | + | }) | |
| 48 | + | ).json(); | |
| 49 | + | if (claim.status === "approved") { | |
| 50 | + | mkdirSync(HOME, { recursive: true }); | |
| 51 | + | writeFileSync(CONFIG, JSON.stringify({ api: API, token: claim.token }, null, 2)); | |
| 52 | + | try { | |
| 53 | + | chmodSync(CONFIG, 0o600); | |
| 54 | + | } catch {} | |
| 55 | + | console.log(`Signed in as ${claim.username}.`); | |
| 56 | + | return; | |
| 57 | + | } | |
| 58 | + | if (claim.status === "denied" || claim.status === "expired") { | |
| 59 | + | throw new Error(`Sign-in was ${claim.status}.`); | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | throw new Error("Sign-in timed out."); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /** Adds g1t's hook to Claude Code's settings, keeping everything else. */ | |
| 66 | + | function addHooks() { | |
| 67 | + | let settings = {}; | |
| 68 | + | try { | |
| 69 | + | settings = JSON.parse(readFileSync(SETTINGS, "utf8")); | |
| 70 | + | } catch {} | |
| 71 | + | settings.hooks ??= {}; | |
| 72 | + | for (const [event, matcher] of [ | |
| 73 | + | ["UserPromptSubmit", undefined], | |
| 74 | + | ["PostToolUse", "*"], | |
| 75 | + | ["Stop", undefined], | |
| 76 | + | ]) { | |
| 77 | + | const groups = (settings.hooks[event] ??= []); | |
| 78 | + | const present = groups.some((group) => group.hooks?.some((hook) => hook.command === HOOK)); | |
| 79 | + | if (!present) { | |
| 80 | + | groups.push({ ...(matcher ? { matcher } : {}), hooks: [{ type: "command", command: HOOK, timeout: 10 }] }); | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | mkdirSync(join(homedir(), ".claude"), { recursive: true }); | |
| 84 | + | writeFileSync(SETTINGS, JSON.stringify(settings, null, 2)); | |
| 85 | + | console.log(`Added g1t's hook to ${SETTINGS}.`); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | try { | |
| 89 | + | await signIn(); | |
| 90 | + | addHooks(); | |
| 91 | + | console.log("\nDone. Sessions in a g1t pull request's working copy are now recorded onto it."); | |
| 92 | + | console.log("To give Claude Code g1t's tools as well, run:"); | |
| 93 | + | console.log(" claude mcp add --transport http g1t https://mcp.g1t.sh\n"); | |
| 94 | + | } catch (error) { | |
| 95 | + | console.error(`g1t: ${error.message}`); | |
| 96 | + | process.exit(1); | |
| 97 | + | } |
| 1302 | 1302 | #[serde(default)] | |
| 1303 | 1303 | pub conflict_with: Option<u32>, | |
| 1304 | 1304 | } | |
| 1305 | + | ||
| 1306 | + | ||
| 1307 | + | /// `locate_pull`: where a pull request lives, by its id, for a tool that | |
| 1308 | + | /// knows only the fork it is working in (`g1t.sh/pulls/<id>`). Returns | |
| 1309 | + | /// `Outcome<LocatedPull>`; not found for anyone who cannot see it. | |
| 1310 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 1311 | + | pub struct LocatePullArgs { | |
| 1312 | + | pub id: String, | |
| 1313 | + | pub viewer: Viewer, | |
| 1314 | + | } | |
| 1315 | + | ||
| 1316 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 1317 | + | #[serde(rename_all = "camelCase")] | |
| 1318 | + | pub struct LocatedPull { | |
| 1319 | + | pub repo: RepoPath, | |
| 1320 | + | pub number: u32, | |
| 1321 | + | pub title: String, | |
| 1322 | + | pub status: PullStatus, | |
| 1323 | + | } |
| 1761 | 1761 | "report_queue" => reply(&work.report_queue(args(body)?).await?), | |
| 1762 | 1762 | "remove_from_queue" => reply(&work.remove_from_queue(args(body)?).await?), | |
| 1763 | 1763 | "message_agent" => reply(&work.message_agent(args(body)?).await?), | |
| 1764 | + | "locate_pull" => reply(&work.locate_pull(args(body)?).await?), | |
| 1764 | 1765 | "take_messages" => reply(&work.take_messages(args(body)?).await?), | |
| 1765 | 1766 | "catch_up_job" => reply(&work.catch_up_job(args(body)?).await?), | |
| 1766 | 1767 | "get_settings" => reply(&work.get_settings(args(body)?).await?), |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | impl Work { | |
| 39 | + | pub(crate) async fn locate_pull(&self, a: LocatePullArgs) -> Result<Outcome<LocatedPull>> { | |
| 40 | + | let missing = || Outcome::fail(FailureCode::NotFound, "Pull request not found."); | |
| 41 | + | let Some(pull) = self.pull_by_id(&a.id).await? else { | |
| 42 | + | return Ok(missing()); | |
| 43 | + | }; | |
| 44 | + | let repo: Outcome<g1t_contracts::repos::Repo> = g1t_kit::call( | |
| 45 | + | &self.repos, | |
| 46 | + | "get_by_id", | |
| 47 | + | &g1t_contracts::repos::GetByIdArgs { | |
| 48 | + | id: pull.repo_id.clone(), | |
| 49 | + | viewer: a.viewer, | |
| 50 | + | }, | |
| 51 | + | ) | |
| 52 | + | .await?; | |
| 53 | + | let Outcome::Ok(repo) = repo else { | |
| 54 | + | return Ok(missing()); | |
| 55 | + | }; | |
| 56 | + | Ok(Outcome::Ok(LocatedPull { | |
| 57 | + | repo: g1t_contracts::repos::RepoPath { | |
| 58 | + | namespace: repo.namespace, | |
| 59 | + | name: repo.name, | |
| 60 | + | }, | |
| 61 | + | number: pull.number, | |
| 62 | + | title: pull.title, | |
| 63 | + | status: pull.status, | |
| 64 | + | })) | |
| 65 | + | } | |
| 66 | + | ||
| 39 | 67 | /// Every message sent to the agent on a pull request, oldest first. | |
| 40 | 68 | pub(crate) async fn messages(&self, pull_id: &str) -> Result<Vec<AgentMessage>> { | |
| 41 | 69 | Ok(self |