Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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