Commit

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.

syntaqxcommitted Parent40b7e1aBrowse files
9 files+348−20/9 viewed
+13−0
347347 ("GET", "/openapi.json") => return Response::from_json(&openapi::document()),
348348 ("POST", "/device/code") => return device_code(&mut request, &services).await,
349349 ("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+ }
350363 ("POST", path) if path.starts_with("/queue/") => {
351364 let entry_id = path.trim_start_matches("/queue/").to_owned();
352365 return report_queue(&mut request, &services, &entry_id).await;
+22−0
3232 Ask Claude Code to list the open issues on a repository, or to work on one,
3333 and it will use the tools below.
3434
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+
3557 ## How an agent works on an issue
3658
3759 1. `get_issue` to read the description and acceptance checks, and to see
+4−2
212212 </h2>
213213 <p className="mt-4 max-w-md leading-7 text-muted">
214214 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.
217218 </p>
218219 <div className="mt-6">
219220 <ButtonLink to="https://docs.g1t.sh/guides/bring-your-own-agent/" variant="quiet">
224225 </div>
225226 <div className="space-y-3">
226227 <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" />
227229 <CopyLine prompt text="git clone https://g1t.sh/syntaqx/g1t.git" />
228230 <CopyLine prompt text="curl https://api.g1t.sh/repos/syntaqx/g1t/queue" />
229231 </div>
+23−0
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
+141−0
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));
+97−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+}
+19−0
13021302 #[serde(default)]
13031303 pub conflict_with: Option<u32>,
13041304 }
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+}
+1−0
17611761 "report_queue" => reply(&work.report_queue(args(body)?).await?),
17621762 "remove_from_queue" => reply(&work.remove_from_queue(args(body)?).await?),
17631763 "message_agent" => reply(&work.message_agent(args(body)?).await?),
1764+ "locate_pull" => reply(&work.locate_pull(args(body)?).await?),
17641765 "take_messages" => reply(&work.take_messages(args(body)?).await?),
17651766 "catch_up_job" => reply(&work.catch_up_job(args(body)?).await?),
17661767 "get_settings" => reply(&work.get_settings(args(body)?).await?),
+28−0
3636 }
3737
3838 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+
3967 /// Every message sent to the agent on a pull request, oldest first.
4068 pub(crate) async fn messages(&self, pull_id: &str) -> Result<Vec<AgentMessage>> {
4169 Ok(self