Skip to content
887 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)
Merge branch 'worktree-agent-ab3c375781462035f'11// and, for a plugin's skill typed as /plugin:name,
Work with no issue yet is a session of its own, titled with what it is for, until it becomes a change: Claude Code's plugin titles each session from its first prompt that is not a slash command (its first line or sentence, without code blocks or markdown, up to 80 characters cut on a word), in the background, and only when the session has no title, so a title Claude, a person or an earlier prompt set is never replaced; g1t session attach --title names one by hand and replaces the prompt's. Nothing is titled with prompts kept back.12// g1t session log --kind skill NAME; at the session's
13// first prompt that is not a slash command, its intent
14// as the title, unless it has one: g1t session heartbeat
15// --json, then g1t session attach --title INTENT
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.16// PreToolUse Agent: the task it is given, kept until its subagent starts.
17// Inside a subagent, its own g1t session for the g1t
Merge branch 'worktree-agent-ab3c375781462035f'18// commands (Bash or PowerShell) and g1t MCP claims it makes.
Merge branch 'worktree-agent-ad52ad80841a37960'19// 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.20// (a Skill: g1t session log --kind skill NAME), and
21// g1t session touch for the files it wrote, the commits
22// it made, the branches it pushed and the issues it claimed;
23// after a git push that reached the remote,
24// g1t api update_session -f id=SESSION -f head_commit=SHA;
25// after an Agent call, its subagent's task and, once it
26// finished, its final message
27// SubagentStart g1t session start --parent SESSION --agent-type TYPE …:
28// each subagent is a session of its own, under this one
29// SubagentStop g1t session end --result "its final message" for it
Merge branch 'worktree-agent-ad52ad80841a37960'30// Stop g1t session heartbeat
31// SessionEnd g1t session end --reason REASON
32//
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.33// What a tool returned (tool_response) is never sent, with one exception
34// that is not a tool's output: an Agent call's answer, which is its
35// subagent's final message (as SubagentStop gives it) and its id. Prompts
36// are not sent when G1T_RECORD_PROMPTS=0, and nothing is when G1T_RECORD=0.
Merge branch 'worktree-agent-ad52ad80841a37960'37//
38// Every hook returns at once: the CLI calls run in a detached process of
39// their own, so a slow network never holds Claude up. If `g1t` is not
Merge branch 'worktree-agent-ab3c375781462035f'40// installed, not signed in or older than MIN_CLI (or refuses a command,
41// as an old CLI does), nothing is recorded and Claude Code says so once.
42// Nothing is ever installed. No dependencies; Node 18 or later, and on an
43// older Node it says so once and does nothing.
44//
45// Every CLI call is in test/cli-contract.json, which the tests hold this
46// file and the CLI's own parser (crates/g1t) to.
Merge branch 'worktree-agent-ad52ad80841a37960'47
48import { spawn, spawnSync } from "node:child_process";
49import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
50import { homedir } from "node:os";
51import { delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
52import { fileURLToPath } from "node:url";
53
54/** The most of a prompt that is sent. */
55export const PROMPT_LIMIT = 2000;
56/** The most of a tool call's summary that is sent. */
57export const SUMMARY_LIMIT = 200;
58/** The most of an issue's work context given to Claude. */
59export const CONTEXT_LIMIT = 8000;
Work with no issue yet is a session of its own, titled with what it is for, until it becomes a change: Claude Code's plugin titles each session from its first prompt that is not a slash command (its first line or sentence, without code blocks or markdown, up to 80 characters cut on a word), in the background, and only when the session has no title, so a title Claude, a person or an earlier prompt set is never replaced; g1t session attach --title names one by hand and replaces the prompt's. Nothing is titled with prompts kept back.60/** The most of a prompt's intent that titles a session. */
61export const TITLE_LIMIT = 80;
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.62/** The most of a subagent's task (its description) that is sent. */
63export const TASK_LIMIT = 200;
64/** The most of a subagent's prompt that is sent. */
65export const AGENT_PROMPT_LIMIT = 4000;
66/** The most of a subagent's final message that is sent. */
67export const RESULT_LIMIT = 8000;
Merge branch 'worktree-agent-ad52ad80841a37960'68/** How long SessionStart waits for an issue's work context. */
69const CONTEXT_WAIT_MS = 900;
70/** Exit code the g1t CLI gives when signing in is needed. */
71const EXIT_AUTH = 4;
Merge branch 'worktree-agent-ab3c375781462035f'72/** Exit code the g1t CLI gives for a command it does not take, as a CLI older than the plugin does. */
73const EXIT_USAGE = 2;
74/** The oldest g1t CLI that takes every command the hooks run (test/cli-contract.json's minimum_version). */
75export const MIN_CLI = "0.3.2";
76/** The oldest Node the hooks run on. */
77export const MIN_NODE = 18;
78/** How to update the CLI: its installer, run again. */
79const INSTALL = "`curl -fsSL https://g1t.sh/install | sh` (on Windows, `irm https://g1t.sh/install.ps1 | iex`)";
80/** The tools that run shell commands. */
81const SHELL_TOOLS = new Set(["Bash", "PowerShell"]);
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.82/** The tool that starts subagents (Task before Claude Code renamed it). */
83const AGENT_TOOLS = new Set(["Agent", "Task"]);
84/** Tools that write a file, and where they say which. */
85const WRITERS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
Merge branch 'worktree-agent-ad52ad80841a37960'86
87const SELF = fileURLToPath(import.meta.url);
88
89// --- what to send ------------------------------------------------------
90
91export function clip(text, limit) {
92 const value = String(text ?? "");
93 return value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
94}
95
96/** Hides what looks like a secret in a command line before it is sent. */
97export function redact(text) {
98 return String(text)
99 .replace(/\b(g1t|ghp|gho|ghs|github_pat|glpat|xox[abpr]|sk-ant|sk|rk)[-_][A-Za-z0-9_-]{8,}/g, "$1_…")
100 .replace(/(authorization:\s*(?:bearer|basic|token)\s+)[^\s"']+/gi, "$1…")
101 .replace(/(\b[A-Za-z_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|PRIVATE_KEY)[A-Za-z_]*=)("[^"]*"|'[^']*'|\S+)/gi, "$1…")
102 .replace(/(--(?:token|password|secret)[= ])\S+/gi, "$1…")
103 .replace(/(https?:\/\/)[^\s/:@]+:[^\s/@]+@/g, "$1…@");
104}
105
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.106/** A path as the repository names it: relative to its root, with forward slashes; null outside it. */
107export function repoPath(path, cwd) {
108 if (typeof path !== "string" || !path) return null;
109 if (!isAbsolute(path)) return path.replaceAll("\\", "/").replace(/^\.\//, "");
110 if (!cwd) return null;
111 const rel = relative(cwd, path);
112 if (!rel || rel.startsWith("..") || isAbsolute(rel)) return null;
113 return rel.replaceAll("\\", "/");
114}
115
Merge branch 'worktree-agent-ab3c375781462035f'116/** What a subagent's shell command starts with to act as its session (see preToolUse), in Bash or PowerShell. */
117export function sessionPrefix(tool, session) {
118 return tool === "PowerShell" ? `$env:G1T_SESSION='${session}'; ` : `export G1T_SESSION=${session}; `;
119}
120
121const PREFIX = /^(?:export G1T_SESSION=[A-Za-z0-9_]+; |\$env:G1T_SESSION='[A-Za-z0-9_]+'; )/;
122
Merge branch 'worktree-agent-ad52ad80841a37960'123/** One line on what a tool was called with: never what it returned. */
124export function summarizeTool(name, input, cwd) {
125 const i = input && typeof input === "object" ? input : {};
126 const path = (p) => {
127 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.128 return repoPath(p, cwd) ?? p;
Merge branch 'worktree-agent-ad52ad80841a37960'129 };
130 let what = "";
Merge branch 'worktree-agent-ab3c375781462035f'131 if (typeof i.command === "string") {
132 // Without the session the plugin gave a subagent's command.
133 const command = i.command.replace(PREFIX, "");
134 what = command.split("\n")[0] + (command.includes("\n") ? " …" : "");
135 }
Merge branch 'worktree-agent-ad52ad80841a37960'136 else if (i.file_path || i.notebook_path) what = path(i.file_path ?? i.notebook_path);
137 else if (typeof i.pattern === "string") what = `${i.pattern}${i.path ? ` in ${path(i.path)}` : ""}`;
138 else if (typeof i.url === "string") what = i.url;
139 else if (typeof i.query === "string") what = i.query;
140 else if (typeof i.description === "string") what = i.description;
141 else if (typeof i.action === "string") what = i.action;
142 const line = what ? `${name}: ${redact(what)}` : String(name);
143 return clip(line, SUMMARY_LIMIT);
144}
145
146/** The issue a branch is for, when its name holds the number: 42-fix, issue-42, fix/42, g1t/42-x. */
147export function issueFromBranch(branch) {
148 if (!branch || branch === "HEAD") return null;
149 if (/^(release|releases|hotfix|v\d)/i.test(branch)) return null;
150 const match = /(?:^|[/_-])(?:issues?-?|#)?(\d{1,7})(?=$|[/_-])/i.exec(branch);
151 if (!match) return null;
152 const n = Number(match[1]);
153 return n > 0 ? n : null;
154}
155
156/** The issue a shell command claims or attaches the session to, if any. */
157export function issueFromCommand(command) {
158 if (typeof command !== "string") return null;
159 const patterns = [
160 /\bg1t\s+work\s+claim\s+#?(\d+)/,
161 /\bg1t\s+session\s+(?:start|attach)\b[^|;&\n]*--issue[= ]#?(\d+)/,
162 /\bg1t\s+pr\s+create\b[^|;&\n]*--issue[= ]#?(\d+)/,
163 ];
164 for (const pattern of patterns) {
165 const match = pattern.exec(command);
166 if (match) return Number(match[1]);
167 }
168 return null;
169}
170
171export const isGitPush = (command) =>
172 typeof command === "string" && /(^|[\s;&|(])git(\s+-\S+(\s+[^-\s]\S*)?)*\s+push\b/.test(command);
173
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.174export const isGitCommit = (command) =>
175 typeof command === "string" && /(^|[\s;&|(])git(\s+-\S+(\s+[^-\s]\S*)?)*\s+commit\b/.test(command);
176
177/** The branch a command makes: git checkout -b X, git switch -c X, git branch X. */
178export function branchFromCommand(command) {
179 if (typeof command !== "string") return null;
180 const match =
181 /\bgit\s+(?:checkout\s+-[bB]|switch\s+-[cC]|switch\s+--create)\s+([^\s;&|]+)/.exec(command) ??
182 /\bgit\s+branch\s+(?!-)([^\s;&|]+)/.exec(command);
183 return match ? match[1] : null;
184}
185
186/** A subagent's harness id: the parent's, `/agent/`, and its id without Claude Code's `agent-` prefix. */
187export function subagentHarnessId(parent, agentId) {
188 return `${String(parent).trim()}/agent/${String(agentId).trim().replace(/^agent-/, "")}`;
189}
190
191/** The text of an Agent call's answer: its subagent's final message. */
192export function agentResult(response) {
193 const content = response?.content;
194 if (typeof content === "string") return content;
195 if (!Array.isArray(content)) return "";
196 return content
197 .filter((block) => block && block.type === "text" && typeof block.text === "string")
198 .map((block) => block.text)
199 .join("\n")
200 .trim();
201}
202
Work with no issue yet is a session of its own, titled with what it is for, until it becomes a change: Claude Code's plugin titles each session from its first prompt that is not a slash command (its first line or sentence, without code blocks or markdown, up to 80 characters cut on a word), in the background, and only when the session has no title, so a title Claude, a person or an earlier prompt set is never replaced; g1t session attach --title names one by hand and replaces the prompt's. Nothing is titled with prompts kept back.203/**
204 * What a prompt is for, as a session's title: its first line or sentence,
205 * without code blocks or markdown, whitespace collapsed, clipped at
206 * TITLE_LIMIT on a word with "…". Null for a slash command (/clear,
207 * /g1t:status 4), a turn Claude Code starts itself, or nothing to say.
208 * apps/web/app/lib/coordination.ts reads an untitled session's first
209 * prompt the same way.
210 */
211export function intentTitle(prompt) {
212 let text = String(prompt ?? "").replace(/^\uFEFF/, "");
213 if (/^\s*\//.test(text) || /^\s*<[a-z][\w-]*>/i.test(text)) return null;
214 text = text
215 .replace(/```[\s\S]*?(```|$)/g, "\n")
216 .replace(/~~~[\s\S]*?(~~~|$)/g, "\n")
217 .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
218 .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
219 .replace(/`([^`]*)`/g, "$1");
220 const line = text
221 .split(/\r?\n/)
222 .map((l) => l.replace(/^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)+/, "").replace(/(\*\*|__|~~)(.+?)\1/g, "$2").trim())
223 .find((l) => /[\p{L}\p{N}]/u.test(l));
224 if (!line) return null;
225 // Its first sentence: up to a stop followed by a space.
226 let intent = line.replace(/\s+/g, " ").split(/(?<=[.!?])\s+(?=\S)/)[0].trim();
227 if (intent.length > TITLE_LIMIT) {
228 const cut = intent.slice(0, TITLE_LIMIT - 1);
229 const space = cut.lastIndexOf(" ");
230 intent = `${(space > TITLE_LIMIT / 2 ? cut.slice(0, space) : cut).replace(/[\s,;:.\-–—]+$/, "")}…`;
231 } else {
232 intent = intent.replace(/[\s,;:.]+$/, "");
233 }
234 if (intent.length < 2) return null;
235 return intent[0].toLocaleUpperCase() + intent.slice(1);
236}
237
Merge branch 'worktree-agent-ab3c375781462035f'238/** The plugin skill a prompt runs when it is typed as one: `g1t:work-on-issue` for `/g1t:work-on-issue 42`. */
239export function typedSkill(prompt) {
240 const text = String(prompt ?? "").replace(/^/, "");
241 const match = /^\s*\/([A-Za-z0-9][\w.-]*:[A-Za-z0-9][\w.-]*)(?=\s|$)/.exec(text);
242 return match ? match[1] : null;
243}
244
245/**
246 * A skill's step: its name only. `skill` keys it (see hook), so a skill
247 * typed as /g1t:name, which Claude Code may then also run as a Skill tool
248 * call, is recorded once in the turn.
249 */
250function skillStep(name) {
251 return { args: ["session", "log", "--kind", "skill", "--", clip(name, 100)], skill: name.replace(/^.*:/, "") };
252}
253
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.254/** The g1t MCP tool a call is to, by name: `issue` for mcp__plugin_g1t_g1t__issue or mcp__g1t__issue. */
255export function g1tTool(name) {
256 const match = /^mcp__(?:plugin_g1t_)?g1t__([a-z_]+)$/.exec(String(name ?? ""));
257 return match ? match[1] : null;
258}
259
Merge branch 'worktree-agent-ad52ad80841a37960'260/**
261 * 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.262 * (`steps`), whether to start the session first, and for a subagent's
263 * events, which subagent (`agent`). Pure, so the tests can check it
264 * without a CLI.
Merge branch 'worktree-agent-ad52ad80841a37960'265 */
266export function plan(event, env = process.env) {
267 const name = event?.hook_event_name;
268 const sid = event?.session_id;
269 if (!name || !sid || env.G1T_RECORD === "0") return null;
270 const cwd = event.cwd || process.cwd();
271 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.272 // Inside a subagent, what it does is its own session's.
273 if (event.agent_id) job.agent = { id: String(event.agent_id), type: event.agent_type || null };
Merge branch 'worktree-agent-ad52ad80841a37960'274 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.275 case "SessionStart": {
Merge branch 'worktree-agent-ad52ad80841a37960'276 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.277 const issue = Number(env.G1T_ISSUE);
278 if (issue > 0) job.issue = issue;
Merge branch 'worktree-agent-ad52ad80841a37960'279 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.280 }
Merge branch 'worktree-agent-ab3c375781462035f'281 case "UserPromptSubmit": {
282 const prompt = typeof event.prompt === "string" ? event.prompt.replace(/^/, "") : "";
283 // A turn Claude Code starts itself for a background task carries that
284 // task's output, which is never sent: it is not a prompt of yours.
285 if (!prompt.trim() || /^\s*<task-notification>/.test(prompt)) return null;
286 if (env.G1T_RECORD_PROMPTS !== "0") job.steps.push({ args: ["session", "log", "--kind", "prompt", "--", clip(prompt, PROMPT_LIMIT)] });
287 // A skill typed as /plugin:name is a skill used (its name), whether or not prompts are sent.
288 const skill = typedSkill(prompt);
289 if (skill) job.steps.push(skillStep(skill));
Work with no issue yet is a session of its own, titled with what it is for, until it becomes a change: Claude Code's plugin titles each session from its first prompt that is not a slash command (its first line or sentence, without code blocks or markdown, up to 80 characters cut on a word), in the background, and only when the session has no title, so a title Claude, a person or an earlier prompt set is never replaced; g1t session attach --title names one by hand and replaces the prompt's. Nothing is titled with prompts kept back.290 // What it is for titles the session, until it has a title (see hook and work).
291 const intent = env.G1T_RECORD_PROMPTS === "0" ? null : intentTitle(prompt);
292 if (intent) job.steps.push({ title: intent });
Merge branch 'worktree-agent-ab3c375781462035f'293 return job.steps.length ? job : null;
294 }
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.295 case "SubagentStart":
296 if (!job.agent) return null;
297 job.agent.start = true;
298 return job;
299 case "SubagentStop": {
300 if (!job.agent) return null;
301 const args = ["session", "end", "--reason", "done"];
302 const result = typeof event.last_assistant_message === "string" ? event.last_assistant_message.trim() : "";
Merge branch 'worktree-agent-ab3c375781462035f'303 // --flag=VALUE throughout for text: one that starts with a dash ("- Found …") is still a value.
304 if (result) args.push(`--result=${clip(result, RESULT_LIMIT)}`);
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.305 job.steps.push({ args });
306 return job;
307 }
Merge branch 'worktree-agent-ad52ad80841a37960'308 case "PostToolUse": {
309 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.310 const tool = event.tool_name;
Merge branch 'worktree-agent-ad52ad80841a37960'311 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.312 if (tool === "Skill") {
313 const skill = typeof input.skill === "string" ? input.skill.trim() : "";
314 if (!skill) return null;
315 // Its name, nothing more.
Merge branch 'worktree-agent-ab3c375781462035f'316 job.steps.push(skillStep(skill));
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.317 return job;
318 }
319 job.steps.push({ args: ["session", "log", "--kind", "tool_call", "--", summarizeTool(tool, input, cwd)] });
320 const touch = [];
321 if (WRITERS.has(tool)) {
322 const file = repoPath(input.file_path ?? input.notebook_path, cwd);
Merge branch 'worktree-agent-ab3c375781462035f'323 if (file) touch.push(`--file=${file}`);
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.324 }
325 if (AGENT_TOOLS.has(tool) && !job.agent) {
326 const response = event.tool_response ?? {};
327 const agentId = response.agentId ?? response.agent_id;
328 if (agentId) {
329 job.subagent = {
330 id: String(agentId),
331 type: input.subagent_type || null,
332 task: input.description ? clip(input.description, TASK_LIMIT) : null,
333 prompt: env.G1T_RECORD_PROMPTS === "0" || !input.prompt ? null : clip(input.prompt, AGENT_PROMPT_LIMIT),
334 model: response.resolvedModel || null,
335 result: response.status === "completed" ? clip(agentResult(response), RESULT_LIMIT) || null : null,
336 };
337 }
338 }
Merge branch 'worktree-agent-ab3c375781462035f'339 if (SHELL_TOOLS.has(tool)) {
Merge branch 'worktree-agent-ad52ad80841a37960'340 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.341 if (issue) {
342 job.issue = issue;
343 touch.push("--issue", String(issue));
344 }
345 const branch = branchFromCommand(input.command);
346 if (branch) touch.push("--branch", branch);
347 if (isGitCommit(input.command)) job.steps.push({ touchHead: true });
Merge branch 'worktree-agent-ad52ad80841a37960'348 if (isGitPush(input.command)) job.steps.push({ headCommit: true });
349 }
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.350 const claimed = g1tTool(tool) === "issue" && input.action === "claim" && Number(input.number) > 0 ? Number(input.number) : null;
351 if (claimed) {
352 job.issue = claimed;
353 touch.push("--issue", String(claimed));
354 }
355 if (touch.length) job.steps.push({ args: ["session", "touch", ...touch] });
Merge branch 'worktree-agent-ad52ad80841a37960'356 return job;
357 }
358 case "Stop":
359 job.steps.push({ args: ["session", "heartbeat"] });
360 return job;
361 case "SessionEnd":
362 job.steps.push({ args: ["session", "end", "--reason", String(event.reason || "other")] });
363 job.end = true;
364 return job;
365 default:
366 return null;
367 }
368}
369
370// --- the machine -------------------------------------------------------
371
372function git(cwd, args) {
373 const out = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true, timeout: 3000 });
374 return out.status === 0 ? out.stdout.trim() : null;
375}
376
377/** The g1t CLI on PATH, without running it. G1T_BIN names another. */
378export function findCli(env = process.env) {
379 if (env.G1T_BIN) return existsSync(env.G1T_BIN) ? env.G1T_BIN : null;
380 const names = process.platform === "win32" ? ["g1t.exe", "g1t"] : ["g1t"];
381 for (const dir of (env.PATH ?? env.Path ?? "").split(delimiter)) {
382 if (!dir) continue;
383 for (const name of names) {
384 const candidate = join(dir.replace(/^"|"$/g, ""), name);
385 try {
386 if (statSync(candidate).isFile()) return candidate;
387 } catch {}
388 }
389 }
390 return null;
391}
392
393/** Runs the CLI. A .js or .mjs G1T_BIN (the tests' stand-in) runs under Node. */
394function cli(bin, args, options) {
395 const script = /\.m?js$/.test(bin);
396 return spawnSync(script ? process.execPath : bin, script ? [bin, ...args] : args, {
397 encoding: "utf8",
398 windowsHide: true,
399 ...options,
400 });
401}
402
403/** Where the plugin keeps its state: Claude Code's data folder for it. */
404export function dataDir(env = process.env) {
405 return env.CLAUDE_PLUGIN_DATA || join(homedir(), ".g1t", "claude-code");
406}
407
408function readJson(path, fallback) {
409 try {
410 return JSON.parse(readFileSync(path, "utf8"));
411 } catch {
412 return fallback;
413 }
414}
415
416function write(path, text) {
417 try {
418 mkdirSync(dirname(path), { recursive: true });
419 writeFileSync(path, text);
420 } catch {}
421}
422
423const readText = (path) => {
424 try {
425 return readFileSync(path, "utf8").trim() || null;
426 } catch {
427 return null;
428 }
429};
430
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.431const safeName = (text) => String(text).replace(/[^A-Za-z0-9_-]/g, "_");
432
433/**
434 * This session's files: its marker (it is in a g1t working copy), its g1t
435 * session id, its issue, the Agent calls waiting for their subagents, and
436 * each subagent's g1t session id.
437 */
Merge branch 'worktree-agent-ad52ad80841a37960'438export function sessionFiles(sid, env = process.env) {
439 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.440 const safe = safeName(sid);
441 return {
442 dir,
443 prefix: `${safe}.`,
444 marker: join(dir, `${safe}.json`),
445 id: join(dir, `${safe}.id`),
446 issue: join(dir, `${safe}.issue`),
Merge branch 'worktree-agent-ab3c375781462035f'447 turn: join(dir, `${safe}.turn`),
Work with no issue yet is a session of its own, titled with what it is for, until it becomes a change: Claude Code's plugin titles each session from its first prompt that is not a slash command (its first line or sentence, without code blocks or markdown, up to 80 characters cut on a word), in the background, and only when the session has no title, so a title Claude, a person or an earlier prompt set is never replaced; g1t session attach --title names one by hand and replaces the prompt's. Nothing is titled with prompts kept back.448 titled: join(dir, `${safe}.titled`),
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.449 pending: join(dir, `${safe}.pending.json`),
450 agent: (agentId) => join(dir, `${safe}.agent.${safeName(String(agentId).replace(/^agent-/, ""))}.id`),
451 };
Merge branch 'worktree-agent-ad52ad80841a37960'452}
453
454/** Forgets sessions untouched for a week (ones that never saw SessionEnd). */
455function prune(dir) {
456 try {
457 const cutoff = Date.now() - 7 * 24 * 3600 * 1000;
458 for (const name of readdirSync(dir)) {
459 const path = join(dir, name);
460 if (statSync(path).mtimeMs < cutoff) rmSync(path, { force: true });
461 }
462 } catch {}
463}
464
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.465// --- Agent calls and their subagents -----------------------------------
466
467/** Keeps an Agent call's task until its subagent starts (PreToolUse). */
468export function rememberTask(files, input) {
469 const pending = readJson(files.pending, []);
470 pending.push({
471 type: input?.subagent_type || null,
472 task: input?.description ? clip(input.description, TASK_LIMIT) : null,
473 prompt: input?.prompt ? clip(input.prompt, AGENT_PROMPT_LIMIT) : null,
474 at: Date.now(),
475 });
476 // An Agent call refused or never started is forgotten after an hour.
477 write(files.pending, JSON.stringify(pending.filter((p) => Date.now() - p.at < 3600 * 1000).slice(-50)));
478}
479
480/** The task of the oldest Agent call of `type` still waiting for its subagent, taken. */
481export function takeTask(files, type) {
482 const pending = readJson(files.pending, []);
483 const at = pending.findIndex((p) => !type || !p.type || p.type === type);
484 if (at < 0) return null;
485 const [task] = pending.splice(at, 1);
486 write(files.pending, JSON.stringify(pending));
487 return task;
488}
489
Merge branch 'worktree-agent-ad52ad80841a37960'490// --- saying so once ----------------------------------------------------
491
Merge branch 'worktree-agent-ab3c375781462035f'492const NOT_RECORDED = "g1t: this session is not being recorded on g1t, because";
493
Merge branch 'worktree-agent-ad52ad80841a37960'494const MESSAGES = {
Merge branch 'worktree-agent-ab3c375781462035f'495 missing: () => `${NOT_RECORDED} the g1t CLI is not installed. Install it (https://docs.g1t.sh/guides/cli/) and run \`g1t auth login\`. This is said once.`,
496 signed_out: () => `${NOT_RECORDED} the g1t CLI is not signed in. Run \`g1t auth login\`. This is said once.`,
497 outdated: (version) =>
498 `${NOT_RECORDED} the g1t CLI is ${version}, and the plugin needs ${MIN_CLI} or later. Update it by running the installer again: ${INSTALL}. This is said once.`,
499 refused: (command) =>
500 `${NOT_RECORDED} the g1t CLI refused \`g1t ${command}\`: it is older than the plugin, which needs ${MIN_CLI} or later. Update it by running the installer again: ${INSTALL}. This is said once.`,
501 old_node: (version) =>
502 `${NOT_RECORDED} its hooks need Node ${MIN_NODE} or later, and \`node\` is ${version}. Install a current Node (https://nodejs.org) and start Claude Code again. This is said once.`,
Merge branch 'worktree-agent-ad52ad80841a37960'503};
504
Merge branch 'worktree-agent-ab3c375781462035f'505/** Remembers a problem (missing, signed_out, outdated, refused, old_node) and what it was about, or that there is none (null). */
506export function setProblem(env, problem, detail = null) {
Merge branch 'worktree-agent-ad52ad80841a37960'507 const path = join(dataDir(env), "notice.json");
508 const notice = readJson(path, {});
Merge branch 'worktree-agent-ab3c375781462035f'509 if ((notice.problem ?? null) === problem && (notice.detail ?? null) === detail) return;
510 write(path, JSON.stringify({ problem, detail, shown: false }));
Merge branch 'worktree-agent-ad52ad80841a37960'511}
512
513/** The message for a problem not shown yet, marking it shown. */
514export function takeNotice(env) {
515 const path = join(dataDir(env), "notice.json");
516 const notice = readJson(path, {});
517 if (!notice.problem || notice.shown || !MESSAGES[notice.problem]) return null;
518 write(path, JSON.stringify({ ...notice, shown: true }));
Merge branch 'worktree-agent-ab3c375781462035f'519 return MESSAGES[notice.problem](notice.detail);
Merge branch 'worktree-agent-ad52ad80841a37960'520}
521
Merge branch 'worktree-agent-ab3c375781462035f'522/** Whether version a (1.2.3) is older than b. */
523export function older(a, b) {
524 const pa = String(a).split(".").map(Number);
525 const pb = String(b).split(".").map(Number);
526 for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) < (pb[i] || 0);
527 return false;
528}
529
530/**
531 * The CLI's version, from `g1t --version`, asked once for each build of
532 * it: kept with the file's size and time, so updating it asks again.
533 * Null when it cannot be told.
534 */
535export function cliVersion(bin, env = process.env) {
536 let stamp;
537 try {
538 const stat = statSync(bin);
539 stamp = `${bin}|${stat.size}|${stat.mtimeMs}`;
540 } catch {
541 return null;
542 }
543 const path = join(dataDir(env), "cli.json");
544 const kept = readJson(path, {});
545 if (kept.stamp === stamp) return kept.version ?? null;
546 const out = cli(bin, ["--version"], { timeout: 5000, env });
547 const version = out.status === 0 ? (/\b(\d+\.\d+\.\d+)\b/.exec(out.stdout ?? "")?.[1] ?? null) : null;
548 write(path, JSON.stringify({ stamp, version }));
549 return version;
550}
551
Merge branch 'worktree-agent-ad52ad80841a37960'552/** Whether a working copy's remotes are on g1t (g1t.sh, or G1T_HOST's host). */
553export function isG1tRemote(urls, env = process.env) {
554 if (env.G1T_REPO) return true;
555 const hosts = ["g1t.sh", env.G1T_HOST].filter(Boolean).map((h) => h.toLowerCase());
556 return urls.some((url) => hosts.some((host) => url.toLowerCase().includes(host)));
557}
558
559// --- the background half -----------------------------------------------
560
561/** Hands a job to a detached copy of this script and returns at once. */
562function background(job, env = process.env) {
563 const payload = Buffer.from(JSON.stringify(job)).toString("base64url");
564 try {
565 const child = spawn(process.execPath, [SELF, "--worker", payload], {
566 cwd: job.cwd,
567 detached: true,
568 stdio: "ignore",
569 windowsHide: true,
570 env,
571 });
572 child.on("error", () => {});
573 child.unref();
574 } catch {}
575}
576
Work with no issue yet is a session of its own, titled with what it is for, until it becomes a change: Claude Code's plugin titles each session from its first prompt that is not a slash command (its first line or sentence, without code blocks or markdown, up to 80 characters cut on a word), in the background, and only when the session has no title, so a title Claude, a person or an earlier prompt set is never replaced; g1t session attach --title names one by hand and replaces the prompt's. Nothing is titled with prompts kept back.577/** Whether a session, as `--json` prints it, has a title; unreadable reads as having one, so nothing is replaced. */
578export function hasTitle(stdout) {
579 try {
580 const title = JSON.parse(stdout).title;
581 return typeof title === "string" && title.trim() !== "";
582 } catch {
583 return true;
584 }
585}
586
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.587/** The session id in a `g1t session start --json` answer. */
588function startedId(out) {
589 try {
590 return JSON.parse(out.stdout).id ?? null;
591 } catch {
592 return /\bses_[A-Za-z0-9]+/.exec(out.stdout ?? "")?.[0] ?? null;
593 }
594}
595
596/**
597 * Starts a subagent's session under `parentId` (again, when it was: the
598 * same session comes back, taking what is newly said), and keeps its id.
599 */
600function startSubagent(bin, job, files, agent, parentId, opts) {
601 const args = [
602 "session",
603 "start",
604 "--harness",
605 "claude_code",
606 "--harness-session-id",
607 subagentHarnessId(job.sid, agent.id),
608 "--parent",
609 parentId,
610 "--no-remember",
611 ];
Merge branch 'worktree-agent-ab3c375781462035f'612 if (agent.type) args.push(`--agent-type=${agent.type}`);
613 if (agent.task) args.push(`--task=${agent.task}`);
614 if (agent.prompt) args.push(`--prompt=${agent.prompt}`);
615 if (agent.model) args.push(`--model=${agent.model}`);
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.616 args.push("--json");
617 const out = cli(bin, args, { ...opts, env: { ...opts.env, G1T_SESSION: parentId } });
618 if (out.status === EXIT_AUTH) return { auth: true };
Merge branch 'worktree-agent-ab3c375781462035f'619 if (out.status === EXIT_USAGE) return { refused: "session start" };
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.620 const id = out.status === 0 ? startedId(out) : null;
621 if (id) write(files.agent(agent.id), id);
622 return { id };
623}
624
Merge branch 'worktree-agent-ad52ad80841a37960'625/** Starts the session if it needs to, then runs the job's steps in order. */
626export function work(job, env = process.env) {
627 const bin = findCli(env);
628 if (!bin) return setProblem(env, "missing");
Merge branch 'worktree-agent-ab3c375781462035f'629 // An older CLI refuses some of what the hooks send: nothing is sent to it.
630 const version = cliVersion(bin, env);
631 if (version && older(version, MIN_CLI)) return setProblem(env, "outdated", version);
Merge branch 'worktree-agent-ad52ad80841a37960'632 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.633 if (job.issue && !job.agent) write(files.issue, String(job.issue));
Merge branch 'worktree-agent-ad52ad80841a37960'634 const opts = { cwd: job.cwd, timeout: 20000, env: { ...env } };
635 let id = readText(files.id);
636 if (job.start || !id) {
637 const issue = job.issue ?? readText(files.issue);
638 const args = ["session", "start", "--harness", "claude_code", "--harness-session-id", job.sid];
639 if (issue) args.push("--issue", String(issue));
640 args.push("--json");
641 const out = cli(bin, args, opts);
642 if (out.status === EXIT_AUTH) return setProblem(env, "signed_out");
Merge branch 'worktree-agent-ab3c375781462035f'643 if (out.status === EXIT_USAGE) return setProblem(env, "refused", "session start");
Merge branch 'worktree-agent-ad52ad80841a37960'644 if (out.status !== 0) return;
645 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.646 id = startedId(out);
Merge branch 'worktree-agent-ad52ad80841a37960'647 if (!id) return;
648 write(files.id, id);
Merge branch 'worktree-agent-ab3c375781462035f'649 if (job.envFile && /^[A-Za-z0-9_]+$/.test(id)) {
650 // Claude's own g1t commands act in this session, not another one in
651 // the same working copy. A plain assignment, which Claude Code gives
652 // PowerShell commands too; it reads the file at the session's first
653 // command, so a command before this (rare: it is the first second)
654 // uses the working copy's remembered session.
655 try {
656 writeFileSync(job.envFile, `export G1T_SESSION=${id}\n`, { flag: "a" });
657 } catch {}
658 }
Merge branch 'worktree-agent-ad52ad80841a37960'659 }
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.660 // A subagent's events go to its own session, started first if need be.
661 let session = id;
662 if (job.agent) {
663 let child = readText(files.agent(job.agent.id));
664 if (job.agent.start || !child) {
665 const started = startSubagent(bin, job, files, job.agent, id, opts);
666 if (started.auth) return setProblem(env, "signed_out");
Merge branch 'worktree-agent-ab3c375781462035f'667 if (started.refused) return setProblem(env, "refused", started.refused);
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.668 child = started.id ?? child;
669 }
670 if (!child) return;
671 session = child;
672 }
673 opts.env.G1T_SESSION = session;
Merge branch 'worktree-agent-ad52ad80841a37960'674 for (const step of job.steps) {
675 if (step.args) {
Merge branch 'worktree-agent-ab3c375781462035f'676 const args = step.key ? [...step.args.slice(0, -2), `--key=${step.key}`, ...step.args.slice(-2)] : step.args;
677 const out = cli(bin, args, opts);
Merge branch 'worktree-agent-ad52ad80841a37960'678 if (out.status === EXIT_AUTH) return setProblem(env, "signed_out");
Merge branch 'worktree-agent-ab3c375781462035f'679 if (out.status === EXIT_USAGE) return setProblem(env, "refused", step.args.slice(0, 2).join(" "));
Merge branch 'worktree-agent-ad52ad80841a37960'680 } else if (step.headCommit) {
681 // Only once the push reached the remote: HEAD is on a remote branch.
682 const head = git(job.cwd, ["rev-parse", "HEAD"]);
683 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.684 if (head && remote) {
685 cli(bin, ["api", "update_session", "-f", `id=${session}`, "-f", `head_commit=${head}`, "--silent"], opts);
686 const branch = git(job.cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
687 const touch = ["session", "touch", "--commit", head];
688 if (branch && branch !== "HEAD") touch.push("--branch", branch);
689 cli(bin, touch, opts);
690 }
Work with no issue yet is a session of its own, titled with what it is for, until it becomes a change: Claude Code's plugin titles each session from its first prompt that is not a slash command (its first line or sentence, without code blocks or markdown, up to 80 characters cut on a word), in the background, and only when the session has no title, so a title Claude, a person or an earlier prompt set is never replaced; g1t session attach --title names one by hand and replaces the prompt's. Nothing is titled with prompts kept back.691 } else if (step.title) {
692 // Only a session with no title: one set before (by the agent, a
693 // person, or this at an earlier prompt) is never replaced.
694 const seen = cli(bin, ["session", "heartbeat", "--json"], opts);
695 if (seen.status === EXIT_AUTH) return setProblem(env, "signed_out");
696 if (seen.status === EXIT_USAGE) return setProblem(env, "refused", "session heartbeat");
697 if (seen.status !== 0 || hasTitle(seen.stdout)) continue;
698 const out = cli(bin, ["session", "attach", `--title=${step.title}`], opts);
699 if (out.status === EXIT_USAGE) return setProblem(env, "refused", "session attach");
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.700 } else if (step.touchHead) {
701 const head = git(job.cwd, ["rev-parse", "HEAD"]);
702 if (head) cli(bin, ["session", "touch", "--commit", head], opts);
Merge branch 'worktree-agent-ad52ad80841a37960'703 }
704 }
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.705 // An Agent call answered: its subagent has its task, and once it
706 // finished, what it reported.
707 if (job.subagent) {
708 const started = startSubagent(bin, job, files, job.subagent, id, opts);
709 if (started.id && job.subagent.result) {
Merge branch 'worktree-agent-ab3c375781462035f'710 cli(bin, ["session", "end", "--reason", "done", `--result=${job.subagent.result}`], { ...opts, env: { ...opts.env, G1T_SESSION: started.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.711 }
712 }
713 if (job.end) {
714 try {
715 for (const name of readdirSync(files.dir)) if (name.startsWith(files.prefix)) rmSync(join(files.dir, name), { force: true });
716 } catch {}
717 }
Merge branch 'worktree-agent-ad52ad80841a37960'718}
719
720// --- the hooks ---------------------------------------------------------
721
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.722/** What every session is told about trailers, so commits find their way back. */
723const TRAILER = "Add the trailer `G1t-Session: $G1T_SESSION` to the commits you make (git commit --trailer), so g1t ties them to this session.";
724
Merge branch 'worktree-agent-ad52ad80841a37960'725/**
726 * SessionStart: marks the session as one in a g1t working copy, and
727 * returns what to print: context for Claude, and a notice for you, once.
728 * Null outside a g1t working copy, where nothing is recorded.
729 */
730export function sessionStart(event, env = process.env, bin = findCli(env)) {
731 const cwd = event.cwd || process.cwd();
732 const gitDir = git(cwd, ["rev-parse", "--absolute-git-dir"]);
733 if (!gitDir) return null;
734 // The branch, from HEAD itself: also on a branch with no commits yet.
735 const branch = /^ref: refs\/heads\/(.+)$/m.exec(readText(join(gitDir, "HEAD")) ?? "")?.[1] ?? null;
736 const urls = (git(cwd, ["config", "--get-regexp", "^remote\\..*\\.url$"]) ?? "").split(/\r?\n/);
737 if (!isG1tRemote(urls, env)) return null;
738
739 const files = sessionFiles(event.session_id, env);
740 prune(files.dir);
741 write(files.marker, JSON.stringify({ cwd, git_dir: gitDir }));
742
743 const output = {};
744 if (!bin) setProblem(env, "missing");
745 const notice = takeNotice(env);
746 if (notice) output.systemMessage = notice;
747 if (!bin) return output;
748
749 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.750 const handedOff = Number(env.G1T_ISSUE) || null;
751 const issue = remembered ?? handedOff ?? issueFromBranch(branch);
Merge branch 'worktree-agent-ad52ad80841a37960'752 let context;
753 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.754 const where = remembered ? "this session's claim" : handedOff ? "the work handed to it" : `the branch ${branch}`;
Merge branch 'worktree-agent-ad52ad80841a37960'755 const out = cli(bin, ["work", "context", String(issue)], { cwd, timeout: CONTEXT_WAIT_MS, env });
756 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.757 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'758 } else if (out.status === EXIT_AUTH) {
759 setProblem(env, "signed_out");
760 const now = takeNotice(env);
761 if (now) output.systemMessage = now;
762 } 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.763 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'764 }
765 } else {
Work with no issue yet is a session of its own, titled with what it is for, until it becomes a change: Claude Code's plugin titles each session from its first prompt that is not a slash command (its first line or sentence, without code blocks or markdown, up to 80 characters cut on a word), in the background, and only when the session has no title, so a title Claude, a person or an earlier prompt set is never replaced; g1t session attach --title names one by hand and replaces the prompt's. Nothing is titled with prompts kept back.766 context = `g1t: this session is recorded on g1t and shows to the team, titled with what you are asked to do. Just work. If it turns into a change others should know about (a branch to push, a pull request), first look for its issue (\`g1t work list\`, \`g1t issue list\`) and claim it with \`g1t work claim N\`; if there is none, open and claim one with \`g1t work claim --title "…"\`. Never open issues for questions, exploration or trivial fixes. ${TRAILER}`;
Merge branch 'worktree-agent-ad52ad80841a37960'767 }
768 if (context) output.hookSpecificOutput = { hookEventName: "SessionStart", additionalContext: context };
769 return output;
770}
771
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.772/** What a subagent is told as it starts: it is a session of its own, beside its siblings. */
773export function subagentContext(issue) {
774 const on = issue ? ` on issue #${issue}` : "";
775 const see = issue
776 ? `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.`
777 : "Before you change anything, run `g1t session list --subagents` to see the other subagents at work here.";
778 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}`;
779}
780
781/**
782 * PreToolUse: an Agent call's task is kept for its subagent; inside a
783 * subagent, its g1t commands and g1t claims act as its own session.
784 * Returns what to print, or null.
785 */
786export function preToolUse(event, env = process.env) {
787 const files = sessionFiles(event.session_id, env);
788 const tool = event.tool_name;
789 const input = event.tool_input ?? {};
790 if (AGENT_TOOLS.has(tool) && !event.agent_id) {
791 rememberTask(files, input);
792 return null;
793 }
794 const session = event.agent_id ? readText(files.agent(event.agent_id)) : readText(files.id);
795 if (!session) return null;
796 let updated = null;
797 const command = typeof input.command === "string" ? input.command : "";
Merge branch 'worktree-agent-ab3c375781462035f'798 const namesG1t = /\bg1t(?:\.exe)?\s/.test(command) || /G1T_SESSION\b/.test(command);
799 const setsIt = /G1T_SESSION\s*=/.test(command);
800 // A subagent's g1t commands act as its own session. Only these commands
801 // are changed. Claude Code checks its permission rules against the
802 // changed command; no permission decision is made here.
803 if (SHELL_TOOLS.has(tool) && event.agent_id && namesG1t && !setsIt && /^[A-Za-z0-9_]+$/.test(session)) {
804 updated = { ...input, command: `${sessionPrefix(tool, session)}${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.805 }
806 const g1t = g1tTool(tool);
807 if (g1t === "issue" && ["claim", "release", "handoff"].includes(input.action) && !input.session_id) {
808 updated = { ...input, session_id: session };
809 }
810 return updated ? { hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: updated } } : null;
811}
812
Merge branch 'worktree-agent-ad52ad80841a37960'813/** Runs one hook: what it prints, and the job it hands to the background. */
814export 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.815 if (env.G1T_RECORD === "0") return { output: null, job: null };
816 const name = event?.hook_event_name;
817 if (name === "PreToolUse") {
818 if (!event.session_id || !existsSync(sessionFiles(event.session_id, env).marker)) return { output: null, job: null };
819 return { output: preToolUse(event, env), job: null };
820 }
Merge branch 'worktree-agent-ab3c375781462035f'821 // Each prompt is a turn; a skill is recorded once in a turn, however it was run.
822 const files = event?.session_id ? sessionFiles(event.session_id, env) : null;
823 let turn = files ? Number(readText(files.turn)) || 0 : 0;
824 if (name === "UserPromptSubmit" && files && existsSync(files.marker)) write(files.turn, String(++turn));
Merge branch 'worktree-agent-ad52ad80841a37960'825 const job = plan(event, env);
826 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.827 if (name === "SessionStart") {
Merge branch 'worktree-agent-ad52ad80841a37960'828 const bin = findCli(env);
829 const output = sessionStart(event, env, bin);
830 if (!output) return { output: null, job: null };
Merge branch 'worktree-agent-ab3c375781462035f'831 if (env.CLAUDE_ENV_FILE) job.envFile = env.CLAUDE_ENV_FILE;
Merge branch 'worktree-agent-ad52ad80841a37960'832 return { output: Object.keys(output).length ? output : null, job: bin ? job : null };
833 }
834 // 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.835 if (!existsSync(files.marker) || !findCli(env)) return { output: null, job: null };
Merge branch 'worktree-agent-ab3c375781462035f'836 for (const step of job.steps) if (step.skill) step.key = `skill:${safeName(step.skill)}:${turn}`;
Work with no issue yet is a session of its own, titled with what it is for, until it becomes a change: Claude Code's plugin titles each session from its first prompt that is not a slash command (its first line or sentence, without code blocks or markdown, up to 80 characters cut on a word), in the background, and only when the session has no title, so a title Claude, a person or an earlier prompt set is never replaced; g1t session attach --title names one by hand and replaces the prompt's. Nothing is titled with prompts kept back.837 // The first prompt with an intent titles the session; later ones do not try.
838 if (name === "UserPromptSubmit" && job.steps.some((step) => step.title)) {
839 if (existsSync(files.titled)) job.steps = job.steps.filter((step) => !step.title);
840 else write(files.titled, "1");
841 }
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.842 if (name === "SubagentStart") {
843 const task = takeTask(files, job.agent.type);
844 if (task) Object.assign(job.agent, { task: task.task, prompt: env.G1T_RECORD_PROMPTS === "0" ? null : task.prompt });
845 const issue = Number(readText(files.issue)) || null;
846 return { output: { hookSpecificOutput: { hookEventName: "SubagentStart", additionalContext: subagentContext(issue) } }, job };
847 }
848 const notice = name === "UserPromptSubmit" ? takeNotice(env) : null;
Merge branch 'worktree-agent-ad52ad80841a37960'849 return { output: notice ? { systemMessage: notice } : null, job };
850}
851
Merge branch 'worktree-agent-ab3c375781462035f'852/**
853 * On a Node older than MIN_NODE, nothing is recorded: what to print (the
854 * notice, once, at a session's start or a prompt), or null. Undefined on a
855 * current Node.
856 */
857export function oldNode(event, env = process.env, version = process.versions.node) {
858 if (Number(String(version).split(".")[0]) >= MIN_NODE) return undefined;
859 if (env.G1T_RECORD === "0" || !["SessionStart", "UserPromptSubmit"].includes(event?.hook_event_name)) return null;
860 setProblem(env, "old_node", `v${version}`);
861 const notice = takeNotice(env);
862 return notice ? { systemMessage: notice } : null;
863}
864
Merge branch 'worktree-agent-ad52ad80841a37960'865async function main() {
866 if (process.argv[2] === "--worker") {
867 work(JSON.parse(Buffer.from(process.argv[3], "base64url").toString("utf8")));
868 return;
869 }
870 let raw = "";
871 for await (const chunk of process.stdin) raw += chunk;
Merge branch 'worktree-agent-ab3c375781462035f'872 const event = JSON.parse(raw || "{}");
873 const old = oldNode(event);
874 if (old !== undefined) {
875 if (old) process.stdout.write(JSON.stringify(old));
876 return;
877 }
878 const { output, job } = hook(event);
Merge branch 'worktree-agent-ad52ad80841a37960'879 if (output) process.stdout.write(JSON.stringify(output));
880 if (job) background(job);
881}
882
883if (process.argv[1] && resolve(process.argv[1]) === SELF) {
884 main()
885 .catch(() => {})
886 .finally(() => process.exit(0));
887}

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