Skip to content
694 linesCodeBlameRaw

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.

Merge branch 'worktree-agent-ad52ad80841a37960'1#!/usr/bin/env node
2// g1t's Claude Code hooks. Claude Code runs this file for SessionStart,
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.3// UserPromptSubmit, PreToolUse, PostToolUse, SubagentStart, SubagentStop,
4// Stop and SessionEnd, with the event as JSON on stdin. In a working copy of
5// a g1t repository it records the session on g1t through the `g1t` CLI:
Merge branch 'worktree-agent-ad52ad80841a37960'6//
7// SessionStart g1t session start --harness claude_code --harness-session-id ID
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.8// and, when the branch, G1T_ISSUE or the session names an
9// issue, g1t work context N, given to Claude as context
Merge branch 'worktree-agent-ad52ad80841a37960'10// UserPromptSubmit g1t session log --kind prompt TEXT (first 2,000 characters)
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.11// PreToolUse Agent: the task it is given, kept until its subagent starts.
12// Inside a subagent, its own g1t session for the g1t
13// commands and g1t MCP claims it makes.
Merge branch 'worktree-agent-ad52ad80841a37960'14// PostToolUse g1t session log --kind tool_call "Tool: what it was called with"
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.15// (a Skill: g1t session log --kind skill NAME), and
16// g1t session touch for the files it wrote, the commits
17// it made, the branches it pushed and the issues it claimed;
18// after a git push that reached the remote,
19// g1t api update_session -f id=SESSION -f head_commit=SHA;
20// after an Agent call, its subagent's task and, once it
21// finished, its final message
22// SubagentStart g1t session start --parent SESSION --agent-type TYPE …:
23// each subagent is a session of its own, under this one
24// SubagentStop g1t session end --result "its final message" for it
Merge branch 'worktree-agent-ad52ad80841a37960'25// Stop g1t session heartbeat
26// SessionEnd g1t session end --reason REASON
27//
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.28// What a tool returned (tool_response) is never sent, with one exception
29// that is not a tool's output: an Agent call's answer, which is its
30// subagent's final message (as SubagentStop gives it) and its id. Prompts
31// are not sent when G1T_RECORD_PROMPTS=0, and nothing is when G1T_RECORD=0.
Merge branch 'worktree-agent-ad52ad80841a37960'32//
33// Every hook returns at once: the CLI calls run in a detached process of
34// their own, so a slow network never holds Claude up. If `g1t` is not
35// installed or not signed in, nothing is recorded and Claude Code says so
36// once. Nothing is ever installed. No dependencies; Node 18 or later.
37
38import { spawn, spawnSync } from "node:child_process";
39import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
40import { homedir } from "node:os";
41import { delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
42import { fileURLToPath } from "node:url";
43
44/** The most of a prompt that is sent. */
45export const PROMPT_LIMIT = 2000;
46/** The most of a tool call's summary that is sent. */
47export const SUMMARY_LIMIT = 200;
48/** The most of an issue's work context given to Claude. */
49export const CONTEXT_LIMIT = 8000;
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.50/** The most of a subagent's task (its description) that is sent. */
51export const TASK_LIMIT = 200;
52/** The most of a subagent's prompt that is sent. */
53export const AGENT_PROMPT_LIMIT = 4000;
54/** The most of a subagent's final message that is sent. */
55export const RESULT_LIMIT = 8000;
Merge branch 'worktree-agent-ad52ad80841a37960'56/** How long SessionStart waits for an issue's work context. */
57const CONTEXT_WAIT_MS = 900;
58/** Exit code the g1t CLI gives when signing in is needed. */
59const EXIT_AUTH = 4;
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.60/** The tool that starts subagents (Task before Claude Code renamed it). */
61const AGENT_TOOLS = new Set(["Agent", "Task"]);
62/** Tools that write a file, and where they say which. */
63const WRITERS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
Merge branch 'worktree-agent-ad52ad80841a37960'64
65const SELF = fileURLToPath(import.meta.url);
66
67// --- what to send ------------------------------------------------------
68
69export function clip(text, limit) {
70 const value = String(text ?? "");
71 return value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
72}
73
74/** Hides what looks like a secret in a command line before it is sent. */
75export function redact(text) {
76 return String(text)
77 .replace(/\b(g1t|ghp|gho|ghs|github_pat|glpat|xox[abpr]|sk-ant|sk|rk)[-_][A-Za-z0-9_-]{8,}/g, "$1_…")
78 .replace(/(authorization:\s*(?:bearer|basic|token)\s+)[^\s"']+/gi, "$1…")
79 .replace(/(\b[A-Za-z_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|PRIVATE_KEY)[A-Za-z_]*=)("[^"]*"|'[^']*'|\S+)/gi, "$1…")
80 .replace(/(--(?:token|password|secret)[= ])\S+/gi, "$1…")
81 .replace(/(https?:\/\/)[^\s/:@]+:[^\s/@]+@/g, "$1…@");
82}
83
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.84/** A path as the repository names it: relative to its root, with forward slashes; null outside it. */
85export function repoPath(path, cwd) {
86 if (typeof path !== "string" || !path) return null;
87 if (!isAbsolute(path)) return path.replaceAll("\\", "/").replace(/^\.\//, "");
88 if (!cwd) return null;
89 const rel = relative(cwd, path);
90 if (!rel || rel.startsWith("..") || isAbsolute(rel)) return null;
91 return rel.replaceAll("\\", "/");
92}
93
Merge branch 'worktree-agent-ad52ad80841a37960'94/** One line on what a tool was called with: never what it returned. */
95export function summarizeTool(name, input, cwd) {
96 const i = input && typeof input === "object" ? input : {};
97 const path = (p) => {
98 if (typeof p !== "string") return "";
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.99 return repoPath(p, cwd) ?? p;
Merge branch 'worktree-agent-ad52ad80841a37960'100 };
101 let what = "";
102 if (typeof i.command === "string") what = i.command.split("\n")[0] + (i.command.includes("\n") ? " …" : "");
103 else if (i.file_path || i.notebook_path) what = path(i.file_path ?? i.notebook_path);
104 else if (typeof i.pattern === "string") what = `${i.pattern}${i.path ? ` in ${path(i.path)}` : ""}`;
105 else if (typeof i.url === "string") what = i.url;
106 else if (typeof i.query === "string") what = i.query;
107 else if (typeof i.description === "string") what = i.description;
108 else if (typeof i.action === "string") what = i.action;
109 const line = what ? `${name}: ${redact(what)}` : String(name);
110 return clip(line, SUMMARY_LIMIT);
111}
112
113/** The issue a branch is for, when its name holds the number: 42-fix, issue-42, fix/42, g1t/42-x. */
114export function issueFromBranch(branch) {
115 if (!branch || branch === "HEAD") return null;
116 if (/^(release|releases|hotfix|v\d)/i.test(branch)) return null;
117 const match = /(?:^|[/_-])(?:issues?-?|#)?(\d{1,7})(?=$|[/_-])/i.exec(branch);
118 if (!match) return null;
119 const n = Number(match[1]);
120 return n > 0 ? n : null;
121}
122
123/** The issue a shell command claims or attaches the session to, if any. */
124export function issueFromCommand(command) {
125 if (typeof command !== "string") return null;
126 const patterns = [
127 /\bg1t\s+work\s+claim\s+#?(\d+)/,
128 /\bg1t\s+session\s+(?:start|attach)\b[^|;&\n]*--issue[= ]#?(\d+)/,
129 /\bg1t\s+pr\s+create\b[^|;&\n]*--issue[= ]#?(\d+)/,
130 ];
131 for (const pattern of patterns) {
132 const match = pattern.exec(command);
133 if (match) return Number(match[1]);
134 }
135 return null;
136}
137
138export const isGitPush = (command) =>
139 typeof command === "string" && /(^|[\s;&|(])git(\s+-\S+(\s+[^-\s]\S*)?)*\s+push\b/.test(command);
140
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.141export const isGitCommit = (command) =>
142 typeof command === "string" && /(^|[\s;&|(])git(\s+-\S+(\s+[^-\s]\S*)?)*\s+commit\b/.test(command);
143
144/** The branch a command makes: git checkout -b X, git switch -c X, git branch X. */
145export function branchFromCommand(command) {
146 if (typeof command !== "string") return null;
147 const match =
148 /\bgit\s+(?:checkout\s+-[bB]|switch\s+-[cC]|switch\s+--create)\s+([^\s;&|]+)/.exec(command) ??
149 /\bgit\s+branch\s+(?!-)([^\s;&|]+)/.exec(command);
150 return match ? match[1] : null;
151}
152
153/** A subagent's harness id: the parent's, `/agent/`, and its id without Claude Code's `agent-` prefix. */
154export function subagentHarnessId(parent, agentId) {
155 return `${String(parent).trim()}/agent/${String(agentId).trim().replace(/^agent-/, "")}`;
156}
157
158/** The text of an Agent call's answer: its subagent's final message. */
159export function agentResult(response) {
160 const content = response?.content;
161 if (typeof content === "string") return content;
162 if (!Array.isArray(content)) return "";
163 return content
164 .filter((block) => block && block.type === "text" && typeof block.text === "string")
165 .map((block) => block.text)
166 .join("\n")
167 .trim();
168}
169
170/** The g1t MCP tool a call is to, by name: `issue` for mcp__plugin_g1t_g1t__issue or mcp__g1t__issue. */
171export function g1tTool(name) {
172 const match = /^mcp__(?:plugin_g1t_)?g1t__([a-z_]+)$/.exec(String(name ?? ""));
173 return match ? match[1] : null;
174}
175
Merge branch 'worktree-agent-ad52ad80841a37960'176/**
177 * What a hook event asks for: the CLI calls to run in the background
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.178 * (`steps`), whether to start the session first, and for a subagent's
179 * events, which subagent (`agent`). Pure, so the tests can check it
180 * without a CLI.
Merge branch 'worktree-agent-ad52ad80841a37960'181 */
182export function plan(event, env = process.env) {
183 const name = event?.hook_event_name;
184 const sid = event?.session_id;
185 if (!name || !sid || env.G1T_RECORD === "0") return null;
186 const cwd = event.cwd || process.cwd();
187 const job = { cwd, sid, steps: [] };
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.188 // Inside a subagent, what it does is its own session's.
189 if (event.agent_id) job.agent = { id: String(event.agent_id), type: event.agent_type || null };
Merge branch 'worktree-agent-ad52ad80841a37960'190 switch (name) {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.191 case "SessionStart": {
Merge branch 'worktree-agent-ad52ad80841a37960'192 job.start = true;
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.193 const issue = Number(env.G1T_ISSUE);
194 if (issue > 0) job.issue = issue;
Merge branch 'worktree-agent-ad52ad80841a37960'195 return job;
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.196 }
Merge branch 'worktree-agent-ad52ad80841a37960'197 case "UserPromptSubmit":
198 if (env.G1T_RECORD_PROMPTS === "0" || typeof event.prompt !== "string" || !event.prompt.trim()) return null;
199 job.steps.push({ args: ["session", "log", "--kind", "prompt", "--", clip(event.prompt, PROMPT_LIMIT)] });
200 return job;
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.201 case "SubagentStart":
202 if (!job.agent) return null;
203 job.agent.start = true;
204 return job;
205 case "SubagentStop": {
206 if (!job.agent) return null;
207 const args = ["session", "end", "--reason", "done"];
208 const result = typeof event.last_assistant_message === "string" ? event.last_assistant_message.trim() : "";
209 if (result) args.push("--result", clip(result, RESULT_LIMIT));
210 job.steps.push({ args });
211 return job;
212 }
Merge branch 'worktree-agent-ad52ad80841a37960'213 case "PostToolUse": {
214 if (!event.tool_name) return null;
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.215 const tool = event.tool_name;
Merge branch 'worktree-agent-ad52ad80841a37960'216 const input = event.tool_input ?? {};
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.217 if (tool === "Skill") {
218 const skill = typeof input.skill === "string" ? input.skill.trim() : "";
219 if (!skill) return null;
220 // Its name, nothing more.
221 job.steps.push({ args: ["session", "log", "--kind", "skill", "--", clip(skill, 100)] });
222 return job;
223 }
224 job.steps.push({ args: ["session", "log", "--kind", "tool_call", "--", summarizeTool(tool, input, cwd)] });
225 const touch = [];
226 if (WRITERS.has(tool)) {
227 const file = repoPath(input.file_path ?? input.notebook_path, cwd);
228 if (file) touch.push("--file", file);
229 }
230 if (AGENT_TOOLS.has(tool) && !job.agent) {
231 const response = event.tool_response ?? {};
232 const agentId = response.agentId ?? response.agent_id;
233 if (agentId) {
234 job.subagent = {
235 id: String(agentId),
236 type: input.subagent_type || null,
237 task: input.description ? clip(input.description, TASK_LIMIT) : null,
238 prompt: env.G1T_RECORD_PROMPTS === "0" || !input.prompt ? null : clip(input.prompt, AGENT_PROMPT_LIMIT),
239 model: response.resolvedModel || null,
240 result: response.status === "completed" ? clip(agentResult(response), RESULT_LIMIT) || null : null,
241 };
242 }
243 }
244 if (tool === "Bash") {
Merge branch 'worktree-agent-ad52ad80841a37960'245 const issue = issueFromCommand(input.command);
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.246 if (issue) {
247 job.issue = issue;
248 touch.push("--issue", String(issue));
249 }
250 const branch = branchFromCommand(input.command);
251 if (branch) touch.push("--branch", branch);
252 if (isGitCommit(input.command)) job.steps.push({ touchHead: true });
Merge branch 'worktree-agent-ad52ad80841a37960'253 if (isGitPush(input.command)) job.steps.push({ headCommit: true });
254 }
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.255 const claimed = g1tTool(tool) === "issue" && input.action === "claim" && Number(input.number) > 0 ? Number(input.number) : null;
256 if (claimed) {
257 job.issue = claimed;
258 touch.push("--issue", String(claimed));
259 }
260 if (touch.length) job.steps.push({ args: ["session", "touch", ...touch] });
Merge branch 'worktree-agent-ad52ad80841a37960'261 return job;
262 }
263 case "Stop":
264 job.steps.push({ args: ["session", "heartbeat"] });
265 return job;
266 case "SessionEnd":
267 job.steps.push({ args: ["session", "end", "--reason", String(event.reason || "other")] });
268 job.end = true;
269 return job;
270 default:
271 return null;
272 }
273}
274
275// --- the machine -------------------------------------------------------
276
277function git(cwd, args) {
278 const out = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true, timeout: 3000 });
279 return out.status === 0 ? out.stdout.trim() : null;
280}
281
282/** The g1t CLI on PATH, without running it. G1T_BIN names another. */
283export function findCli(env = process.env) {
284 if (env.G1T_BIN) return existsSync(env.G1T_BIN) ? env.G1T_BIN : null;
285 const names = process.platform === "win32" ? ["g1t.exe", "g1t"] : ["g1t"];
286 for (const dir of (env.PATH ?? env.Path ?? "").split(delimiter)) {
287 if (!dir) continue;
288 for (const name of names) {
289 const candidate = join(dir.replace(/^"|"$/g, ""), name);
290 try {
291 if (statSync(candidate).isFile()) return candidate;
292 } catch {}
293 }
294 }
295 return null;
296}
297
298/** Runs the CLI. A .js or .mjs G1T_BIN (the tests' stand-in) runs under Node. */
299function cli(bin, args, options) {
300 const script = /\.m?js$/.test(bin);
301 return spawnSync(script ? process.execPath : bin, script ? [bin, ...args] : args, {
302 encoding: "utf8",
303 windowsHide: true,
304 ...options,
305 });
306}
307
308/** Where the plugin keeps its state: Claude Code's data folder for it. */
309export function dataDir(env = process.env) {
310 return env.CLAUDE_PLUGIN_DATA || join(homedir(), ".g1t", "claude-code");
311}
312
313function readJson(path, fallback) {
314 try {
315 return JSON.parse(readFileSync(path, "utf8"));
316 } catch {
317 return fallback;
318 }
319}
320
321function write(path, text) {
322 try {
323 mkdirSync(dirname(path), { recursive: true });
324 writeFileSync(path, text);
325 } catch {}
326}
327
328const readText = (path) => {
329 try {
330 return readFileSync(path, "utf8").trim() || null;
331 } catch {
332 return null;
333 }
334};
335
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.336const safeName = (text) => String(text).replace(/[^A-Za-z0-9_-]/g, "_");
337
338/**
339 * This session's files: its marker (it is in a g1t working copy), its g1t
340 * session id, its issue, the Agent calls waiting for their subagents, and
341 * each subagent's g1t session id.
342 */
Merge branch 'worktree-agent-ad52ad80841a37960'343export function sessionFiles(sid, env = process.env) {
344 const dir = join(dataDir(env), "sessions");
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.345 const safe = safeName(sid);
346 return {
347 dir,
348 prefix: `${safe}.`,
349 marker: join(dir, `${safe}.json`),
350 id: join(dir, `${safe}.id`),
351 issue: join(dir, `${safe}.issue`),
352 pending: join(dir, `${safe}.pending.json`),
353 agent: (agentId) => join(dir, `${safe}.agent.${safeName(String(agentId).replace(/^agent-/, ""))}.id`),
354 };
Merge branch 'worktree-agent-ad52ad80841a37960'355}
356
357/** Forgets sessions untouched for a week (ones that never saw SessionEnd). */
358function prune(dir) {
359 try {
360 const cutoff = Date.now() - 7 * 24 * 3600 * 1000;
361 for (const name of readdirSync(dir)) {
362 const path = join(dir, name);
363 if (statSync(path).mtimeMs < cutoff) rmSync(path, { force: true });
364 }
365 } catch {}
366}
367
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.368// --- Agent calls and their subagents -----------------------------------
369
370/** Keeps an Agent call's task until its subagent starts (PreToolUse). */
371export function rememberTask(files, input) {
372 const pending = readJson(files.pending, []);
373 pending.push({
374 type: input?.subagent_type || null,
375 task: input?.description ? clip(input.description, TASK_LIMIT) : null,
376 prompt: input?.prompt ? clip(input.prompt, AGENT_PROMPT_LIMIT) : null,
377 at: Date.now(),
378 });
379 // An Agent call refused or never started is forgotten after an hour.
380 write(files.pending, JSON.stringify(pending.filter((p) => Date.now() - p.at < 3600 * 1000).slice(-50)));
381}
382
383/** The task of the oldest Agent call of `type` still waiting for its subagent, taken. */
384export function takeTask(files, type) {
385 const pending = readJson(files.pending, []);
386 const at = pending.findIndex((p) => !type || !p.type || p.type === type);
387 if (at < 0) return null;
388 const [task] = pending.splice(at, 1);
389 write(files.pending, JSON.stringify(pending));
390 return task;
391}
392
Merge branch 'worktree-agent-ad52ad80841a37960'393// --- saying so once ----------------------------------------------------
394
395const MESSAGES = {
396 missing:
397 "g1t: this session is not being recorded on g1t, because the g1t CLI is not installed. Install it (https://docs.g1t.sh/guides/cli/) and run `g1t auth login`. This is said once.",
398 signed_out:
399 "g1t: this session is not being recorded on g1t, because the g1t CLI is not signed in. Run `g1t auth login`. This is said once.",
400};
401
402/** Remembers a problem (missing, signed_out), or that there is none (null). */
403export function setProblem(env, problem) {
404 const path = join(dataDir(env), "notice.json");
405 const notice = readJson(path, {});
406 if ((notice.problem ?? null) === problem) return;
407 write(path, JSON.stringify({ problem, shown: false }));
408}
409
410/** The message for a problem not shown yet, marking it shown. */
411export function takeNotice(env) {
412 const path = join(dataDir(env), "notice.json");
413 const notice = readJson(path, {});
414 if (!notice.problem || notice.shown || !MESSAGES[notice.problem]) return null;
415 write(path, JSON.stringify({ ...notice, shown: true }));
416 return MESSAGES[notice.problem];
417}
418
419/** Whether a working copy's remotes are on g1t (g1t.sh, or G1T_HOST's host). */
420export function isG1tRemote(urls, env = process.env) {
421 if (env.G1T_REPO) return true;
422 const hosts = ["g1t.sh", env.G1T_HOST].filter(Boolean).map((h) => h.toLowerCase());
423 return urls.some((url) => hosts.some((host) => url.toLowerCase().includes(host)));
424}
425
426// --- the background half -----------------------------------------------
427
428/** Hands a job to a detached copy of this script and returns at once. */
429function background(job, env = process.env) {
430 const payload = Buffer.from(JSON.stringify(job)).toString("base64url");
431 try {
432 const child = spawn(process.execPath, [SELF, "--worker", payload], {
433 cwd: job.cwd,
434 detached: true,
435 stdio: "ignore",
436 windowsHide: true,
437 env,
438 });
439 child.on("error", () => {});
440 child.unref();
441 } catch {}
442}
443
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.444/** The session id in a `g1t session start --json` answer. */
445function startedId(out) {
446 try {
447 return JSON.parse(out.stdout).id ?? null;
448 } catch {
449 return /\bses_[A-Za-z0-9]+/.exec(out.stdout ?? "")?.[0] ?? null;
450 }
451}
452
453/**
454 * Starts a subagent's session under `parentId` (again, when it was: the
455 * same session comes back, taking what is newly said), and keeps its id.
456 */
457function startSubagent(bin, job, files, agent, parentId, opts) {
458 const args = [
459 "session",
460 "start",
461 "--harness",
462 "claude_code",
463 "--harness-session-id",
464 subagentHarnessId(job.sid, agent.id),
465 "--parent",
466 parentId,
467 "--no-remember",
468 ];
469 if (agent.type) args.push("--agent-type", String(agent.type));
470 if (agent.task) args.push("--task", agent.task);
471 if (agent.prompt) args.push("--prompt", agent.prompt);
472 if (agent.model) args.push("--model", String(agent.model));
473 args.push("--json");
474 const out = cli(bin, args, { ...opts, env: { ...opts.env, G1T_SESSION: parentId } });
475 if (out.status === EXIT_AUTH) return { auth: true };
476 const id = out.status === 0 ? startedId(out) : null;
477 if (id) write(files.agent(agent.id), id);
478 return { id };
479}
480
Merge branch 'worktree-agent-ad52ad80841a37960'481/** Starts the session if it needs to, then runs the job's steps in order. */
482export function work(job, env = process.env) {
483 const bin = findCli(env);
484 if (!bin) return setProblem(env, "missing");
485 const files = sessionFiles(job.sid, env);
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.486 if (job.issue && !job.agent) write(files.issue, String(job.issue));
Merge branch 'worktree-agent-ad52ad80841a37960'487 const opts = { cwd: job.cwd, timeout: 20000, env: { ...env } };
488 let id = readText(files.id);
489 if (job.start || !id) {
490 const issue = job.issue ?? readText(files.issue);
491 const args = ["session", "start", "--harness", "claude_code", "--harness-session-id", job.sid];
492 if (issue) args.push("--issue", String(issue));
493 args.push("--json");
494 const out = cli(bin, args, opts);
495 if (out.status === EXIT_AUTH) return setProblem(env, "signed_out");
496 if (out.status !== 0) return;
497 setProblem(env, null);
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.498 id = startedId(out);
Merge branch 'worktree-agent-ad52ad80841a37960'499 if (!id) return;
500 write(files.id, id);
501 }
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.502 // A subagent's events go to its own session, started first if need be.
503 let session = id;
504 if (job.agent) {
505 let child = readText(files.agent(job.agent.id));
506 if (job.agent.start || !child) {
507 const started = startSubagent(bin, job, files, job.agent, id, opts);
508 if (started.auth) return setProblem(env, "signed_out");
509 child = started.id ?? child;
510 }
511 if (!child) return;
512 session = child;
513 }
514 opts.env.G1T_SESSION = session;
Merge branch 'worktree-agent-ad52ad80841a37960'515 for (const step of job.steps) {
516 if (step.args) {
517 const out = cli(bin, step.args, opts);
518 if (out.status === EXIT_AUTH) return setProblem(env, "signed_out");
519 } else if (step.headCommit) {
520 // Only once the push reached the remote: HEAD is on a remote branch.
521 const head = git(job.cwd, ["rev-parse", "HEAD"]);
522 const remote = git(job.cwd, ["branch", "-r", "--contains", "HEAD"]);
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.523 if (head && remote) {
524 cli(bin, ["api", "update_session", "-f", `id=${session}`, "-f", `head_commit=${head}`, "--silent"], opts);
525 const branch = git(job.cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
526 const touch = ["session", "touch", "--commit", head];
527 if (branch && branch !== "HEAD") touch.push("--branch", branch);
528 cli(bin, touch, opts);
529 }
530 } else if (step.touchHead) {
531 const head = git(job.cwd, ["rev-parse", "HEAD"]);
532 if (head) cli(bin, ["session", "touch", "--commit", head], opts);
Merge branch 'worktree-agent-ad52ad80841a37960'533 }
534 }
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.535 // An Agent call answered: its subagent has its task, and once it
536 // finished, what it reported.
537 if (job.subagent) {
538 const started = startSubagent(bin, job, files, job.subagent, id, opts);
539 if (started.id && job.subagent.result) {
540 cli(bin, ["session", "end", "--reason", "done", "--result", job.subagent.result], { ...opts, env: { ...opts.env, G1T_SESSION: started.id } });
541 }
542 }
543 if (job.end) {
544 try {
545 for (const name of readdirSync(files.dir)) if (name.startsWith(files.prefix)) rmSync(join(files.dir, name), { force: true });
546 } catch {}
547 }
Merge branch 'worktree-agent-ad52ad80841a37960'548}
549
550// --- the hooks ---------------------------------------------------------
551
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.552/** What every session is told about trailers, so commits find their way back. */
553const TRAILER = "Add the trailer `G1t-Session: $G1T_SESSION` to the commits you make (git commit --trailer), so g1t ties them to this session.";
554
Merge branch 'worktree-agent-ad52ad80841a37960'555/**
556 * SessionStart: marks the session as one in a g1t working copy, and
557 * returns what to print: context for Claude, and a notice for you, once.
558 * Null outside a g1t working copy, where nothing is recorded.
559 */
560export function sessionStart(event, env = process.env, bin = findCli(env)) {
561 const cwd = event.cwd || process.cwd();
562 const gitDir = git(cwd, ["rev-parse", "--absolute-git-dir"]);
563 if (!gitDir) return null;
564 // The branch, from HEAD itself: also on a branch with no commits yet.
565 const branch = /^ref: refs\/heads\/(.+)$/m.exec(readText(join(gitDir, "HEAD")) ?? "")?.[1] ?? null;
566 const urls = (git(cwd, ["config", "--get-regexp", "^remote\\..*\\.url$"]) ?? "").split(/\r?\n/);
567 if (!isG1tRemote(urls, env)) return null;
568
569 const files = sessionFiles(event.session_id, env);
570 prune(files.dir);
571 write(files.marker, JSON.stringify({ cwd, git_dir: gitDir }));
572
573 const output = {};
574 if (!bin) setProblem(env, "missing");
575 const notice = takeNotice(env);
576 if (notice) output.systemMessage = notice;
577 if (!bin) return output;
578
579 if (env.CLAUDE_ENV_FILE) {
580 // Claude's own `g1t` commands then act in this session, not in another
581 // one in the same working copy. Read as each command runs, since the
582 // session's id arrives after this hook has returned.
583 const p = files.id.replaceAll("\\", "/");
584 try {
585 writeFileSync(env.CLAUDE_ENV_FILE, `[ -f "${p}" ] && export G1T_SESSION="$(cat "${p}")"\n`, { flag: "a" });
586 } catch {}
587 }
588
589 const remembered = Number(readText(files.issue)) || null;
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.590 const handedOff = Number(env.G1T_ISSUE) || null;
591 const issue = remembered ?? handedOff ?? issueFromBranch(branch);
Merge branch 'worktree-agent-ad52ad80841a37960'592 let context;
593 if (issue) {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.594 const where = remembered ? "this session's claim" : handedOff ? "the work handed to it" : `the branch ${branch}`;
Merge branch 'worktree-agent-ad52ad80841a37960'595 const out = cli(bin, ["work", "context", String(issue)], { cwd, timeout: CONTEXT_WAIT_MS, env });
596 if (out.status === 0 && out.stdout.trim()) {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.597 context = `g1t: this session is working on issue #${issue} (from ${where}). Its work context on g1t:\n\n${clip(out.stdout.trim(), CONTEXT_LIMIT)}\n\nIf it is not claimed by this session yet, claim it with \`g1t work claim ${issue}\` before you start. ${TRAILER}`;
Merge branch 'worktree-agent-ad52ad80841a37960'598 } else if (out.status === EXIT_AUTH) {
599 setProblem(env, "signed_out");
600 const now = takeNotice(env);
601 if (now) output.systemMessage = now;
602 } else {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.603 context = `g1t: ${where} points at issue #${issue}. Load what is known about it with \`g1t work context ${issue}\`, and claim it with \`g1t work claim ${issue}\` before you start. ${TRAILER}`;
Merge branch 'worktree-agent-ad52ad80841a37960'604 }
605 } else {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.606 context = `g1t: this is a g1t repository. Before starting on an issue, load it with \`g1t work context N\` and claim it with \`g1t work claim N\`, so the people and agents also working here know. ${TRAILER}`;
Merge branch 'worktree-agent-ad52ad80841a37960'607 }
608 if (context) output.hookSpecificOutput = { hookEventName: "SessionStart", additionalContext: context };
609 return output;
610}
611
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.612/** What a subagent is told as it starts: it is a session of its own, beside its siblings. */
613export function subagentContext(issue) {
614 const on = issue ? ` on issue #${issue}` : "";
615 const see = issue
616 ? `Before you change anything, run \`g1t work context ${issue}\` and \`g1t session list --issue ${issue} --subagents\`: other subagents of the same session may be on it, and you see what each touches.`
617 : "Before you change anything, run `g1t session list --subagents` to see the other subagents at work here.";
618 return `g1t: you are a subagent of a session recorded on g1t${on}, and g1t records you as a session of your own under it: the g1t commands and g1t tools you use act as that session. ${see} Claim only the issue you take on (\`g1t work claim N --note "what you are doing"\`), and if g1t reports an overlap with another session, settle it before going on. ${TRAILER}`;
619}
620
621/**
622 * PreToolUse: an Agent call's task is kept for its subagent; inside a
623 * subagent, its g1t commands and g1t claims act as its own session.
624 * Returns what to print, or null.
625 */
626export function preToolUse(event, env = process.env) {
627 const files = sessionFiles(event.session_id, env);
628 const tool = event.tool_name;
629 const input = event.tool_input ?? {};
630 if (AGENT_TOOLS.has(tool) && !event.agent_id) {
631 rememberTask(files, input);
632 return null;
633 }
634 const session = event.agent_id ? readText(files.agent(event.agent_id)) : readText(files.id);
635 if (!session) return null;
636 let updated = null;
637 const command = typeof input.command === "string" ? input.command : "";
638 const namesG1t = /\bg1t\s/.test(command) || /\$\{?G1T_SESSION\b/.test(command);
639 if (tool === "Bash" && event.agent_id && namesG1t && !command.includes("G1T_SESSION=")) {
640 updated = { ...input, command: `export G1T_SESSION=${session}; ${input.command}` };
641 }
642 const g1t = g1tTool(tool);
643 if (g1t === "issue" && ["claim", "release", "handoff"].includes(input.action) && !input.session_id) {
644 updated = { ...input, session_id: session };
645 }
646 return updated ? { hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: updated } } : null;
647}
648
Merge branch 'worktree-agent-ad52ad80841a37960'649/** Runs one hook: what it prints, and the job it hands to the background. */
650export function hook(event, env = process.env) {
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.651 if (env.G1T_RECORD === "0") return { output: null, job: null };
652 const name = event?.hook_event_name;
653 if (name === "PreToolUse") {
654 if (!event.session_id || !existsSync(sessionFiles(event.session_id, env).marker)) return { output: null, job: null };
655 return { output: preToolUse(event, env), job: null };
656 }
Merge branch 'worktree-agent-ad52ad80841a37960'657 const job = plan(event, env);
658 if (!job) return { output: null, job: null };
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.659 if (name === "SessionStart") {
Merge branch 'worktree-agent-ad52ad80841a37960'660 const bin = findCli(env);
661 const output = sessionStart(event, env, bin);
662 if (!output) return { output: null, job: null };
663 return { output: Object.keys(output).length ? output : null, job: bin ? job : null };
664 }
665 // Only sessions SessionStart found in a g1t working copy are recorded.
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.666 const files = sessionFiles(event.session_id, env);
667 if (!existsSync(files.marker) || !findCli(env)) return { output: null, job: null };
668 if (name === "SubagentStart") {
669 const task = takeTask(files, job.agent.type);
670 if (task) Object.assign(job.agent, { task: task.task, prompt: env.G1T_RECORD_PROMPTS === "0" ? null : task.prompt });
671 const issue = Number(readText(files.issue)) || null;
672 return { output: { hookSpecificOutput: { hookEventName: "SubagentStart", additionalContext: subagentContext(issue) } }, job };
673 }
674 const notice = name === "UserPromptSubmit" ? takeNotice(env) : null;
Merge branch 'worktree-agent-ad52ad80841a37960'675 return { output: notice ? { systemMessage: notice } : null, job };
676}
677
678async function main() {
679 if (process.argv[2] === "--worker") {
680 work(JSON.parse(Buffer.from(process.argv[3], "base64url").toString("utf8")));
681 return;
682 }
683 let raw = "";
684 for await (const chunk of process.stdin) raw += chunk;
685 const { output, job } = hook(JSON.parse(raw || "{}"));
686 if (output) process.stdout.write(JSON.stringify(output));
687 if (job) background(job);
688}
689
690if (process.argv[1] && resolve(process.argv[1]) === SELF) {
691 main()
692 .catch(() => {})
693 .finally(() => process.exit(0));
694}

This file's history is long; its oldest lines are credited to the oldest commit read.