Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1525,7 +1525,7 @@ repository; where several repositories use one, the other commands take the ID.
checkout, runs the command there, and once it exits kills any descendants it
left behind. The command gets Quest's environment without Git's
repository-location variables, such as `GIT_DIR` and `GIT_INDEX_FILE`, which a
Git hook would otherwise point at the repository it ran in; `-c` config is kept. When no other run remains in the checkout, it releases it: the
Git hook would otherwise point at the repository it ran in; `-c` config is kept. When no other run, nor a Claude Code session holding the checkout (see [Claude Code checkouts](#claude-code-checkouts)), remains, it releases it: the
declared scratch is deleted and the checkout keeps only its source. Put a whole
job, such as an agent session or a script that installs, builds, and tests, in
one run, since the next run starts without scratch.
Expand Down Expand Up @@ -1613,7 +1613,9 @@ entries into the file's `hooks`:
"WorktreeCreate": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }],
"WorktreeRemove": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }],
"SubagentStart": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }],
"SubagentStop": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }]
"SubagentStop": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }],
"SessionStart": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }],
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook", "timeout": 60 }] }]
}
}
```
Expand Down Expand Up @@ -1651,9 +1653,17 @@ agent ID, and a workflow agent's for its workflow run, so the hook binds a
workflow agent's checkout to the first subagent that starts in it, the workflow
agent itself. Another `SubagentStop` hook that keeps a subagent running defeats
this: Claude continues the subagent without a `SubagentStart`, so it works on
without its scratch until it reinstalls it. A session's checkout, from
`--worktree`, `EnterWorktree`, a background session, or remote control, is not
released when the session stops; release it with `quest worktree release`.
without its scratch until it reinstalls it.

A session's checkout, from `--worktree`, `EnterWorktree`, a background session,
or remote control, is held by the session that created it and by each session
that starts in it, and released the same way once the last of them ends, except
when `/clear` starts a new session in its place. A session that held it restores
it on resuming, as does any session starting in it, with the same note. A background session that sits idle never
ends, so its checkout stays held. Claude Code gives `SessionEnd` hooks at most a
minute, which the entry's `timeout` asks for; a release cut short or failed
leaves the checkout reserved with the failure in `quest worktree status`, until
a session starts in it or `quest worktree release` finishes it.

Claude never removes a subagent's or workflow agent's checkout itself, so
remove a finished one with `quest worktree remove`, which also recovers one
Expand Down
1 change: 0 additions & 1 deletion quest/m0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,5 @@ ownership-manifest design:
- [Codex cloud off](/quest/m0/codex-cloud-off.md) - automatic Codex cloud reviews stop once no open PR relies on them
- [Codex project-config skip](/quest/m0/codex-project-config.md) - once Codex keeps a supported way to skip a project's `.codex/` layer, reviews keep the user's config
- [Agents get the dev shell from user settings](/quest/m0/user-dev-shell/README.md) - maintainers' own Claude and Codex settings load the dev shell; repositories drop dev-shell hooks, Quest worktree hooks, and `nix develop` instructions, and `quest init` writes no hooks
- [Session checkouts released](/quest/m0/session-checkouts.md) - a Claude session's own checkout is released when the session ends and reclaimed on resume
- [claude rm upstream report](/quest/m0/claude-rm-upstream.md) - posting approved: ask Claude Code to run `WorktreeRemove` instead of advising an unlock
- [Three generic rules in the shared context](/quest/m0/context-rules.md) - `quest context` adds matching conventions, never loosening a flaky test, and short names under a namespace
53 changes: 0 additions & 53 deletions quest/m0/session-checkouts.md

This file was deleted.

7 changes: 5 additions & 2 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,7 +877,7 @@ export function main(argv: string[], out: Output): number | Promise<number> {
.action((id: string) => record(state().restore(id)));
worktree
.command("hook")
.summary("Handle a Claude Code worktree or subagent hook event read on stdin")
.summary("Handle a Claude Code worktree, subagent, or session hook event read on stdin")
.description(
"Handle the Claude Code hook event whose JSON input arrives on stdin, for " +
HOOK_EVENTS.join(", ") +
Expand All @@ -891,7 +891,10 @@ export function main(argv: string[], out: Output): number | Promise<number> {
"restored instead. WorktreeRemove removes the checkout as `remove` does, so Claude keeps one holding " +
"unpreserved work. SubagentStop releases the subagent's own checkout, named agent-<agent_id> by Claude, " +
"or a workflow agent's, named for its run and bound to the first subagent that starts in it, " +
"when it finishes or stops to wait, keeping its .tmp/ workspace for the parent to read until removal, and SubagentStart restores it. Other checkouts are left alone. " +
"when it finishes or stops to wait, keeping its .tmp/ workspace for the parent to read until removal, and SubagentStart restores it. " +
"Every other checkout WorktreeCreate makes is a session's: SessionEnd releases it the same way once no session that " +
"created it or started in it is left, except when /clear starts another in its place, and SessionStart restores it. " +
"Other checkouts are left alone. " +
"Exits 1 on failure, never 2, which would keep a subagent running.",
)
.action(() => {
Expand Down
84 changes: 67 additions & 17 deletions src/cli/worktree-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ import {
} from "./worktree";

/** The Claude Code events `quest worktree hook` handles. */
export const HOOK_EVENTS = ["WorktreeCreate", "WorktreeRemove", "SubagentStart", "SubagentStop"] as const;
export const HOOK_EVENTS = [
"WorktreeCreate",
"WorktreeRemove",
"SubagentStart",
"SubagentStop",
"SessionStart",
"SessionEnd",
] as const;

/**
* The guide's workspace, which holds an agent's reports and logs. Its parent reads them after it stops,
Expand All @@ -34,6 +41,10 @@ interface HookInput {
worktree_path?: string;
/** SubagentStart and SubagentStop: the subagent's ID. */
agent_id?: string;
/** Every event: the Claude Code session it fires in, a subagent's parent session included. */
session_id?: string;
/** SessionEnd: why the session ended, such as `clear` when `/clear` starts a new one in its place. */
reason?: string;
}

function parse(input: unknown): HookInput {
Expand All @@ -43,7 +54,7 @@ function parse(input: unknown): HookInput {
return value as HookInput;
}

function field(input: HookInput, key: "name" | "worktree_path" | "agent_id"): string {
function field(input: HookInput, key: "name" | "worktree_path" | "agent_id" | "session_id"): string {
const value = input[key];
if (typeof value !== "string" || value === "") throw new Error(`${input.hook_event_name} input lacks ${key}`);
return value;
Expand Down Expand Up @@ -126,23 +137,44 @@ function superproject(checkout: string): string | undefined {
return path ? canonical(path) : undefined;
}

/** The registered checkout holding `cwd`, looking through the submodules it sits in. */
async function enclosing(worktrees: Worktrees, cwd: string): Promise<WorktreeRecord | undefined> {
let path = toplevel(cwd);
let record: WorktreeRecord | undefined;
while (path && !record) {
const checkout = path;
record = await waiting(() => worktrees.registered(checkout));
path = superproject(checkout);
}
return record;
}

/** Claude's name for a workflow agent's worktree, `<run ID>-<agent number>`, which carries no agent ID. */
const WORKFLOW_CHECKOUT = /^wf_[0-9a-f]{8}-[0-9a-f]{3}-[0-9]+$/;

/** Whether Claude made the checkout named `name` for a subagent or workflow agent rather than a session. */
function forSubagent(name: string): boolean {
return name.startsWith("agent-") || WORKFLOW_CHECKOUT.test(name);
}

/** The context telling an agent that a release deleted `scratch` from its checkout while it was stopped. */
function reinstall(event: string, scratch: readonly string[]): string | undefined {
const deleted = scratch.filter((path) => path !== WORKSPACE);
if (!deleted.length) return undefined;
const additionalContext =
`Quest released this checkout while you were stopped, deleting its ignored scratch (${deleted.join(", ")}). ` +
"Reinstall or rebuild what you need before using it.";
return `${JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext } })}\n`;
}

/**
* The checkout Claude created for this subagent. Claude names a subagent's worktree `agent-<agent_id>`,
* so a subagent that runs without isolation in another agent's checkout never matches it. A workflow
* agent's worktree is named for its run instead, so it is bound to the first subagent that starts in
* it, which is the workflow agent itself; one that later runs there without isolation never matches.
*/
async function subagentCheckout(worktrees: Worktrees, input: HookInput): Promise<WorktreeRecord | undefined> {
let path = toplevel(input.cwd);
let record: WorktreeRecord | undefined;
while (path && !record) {
const checkout = path;
record = await waiting(() => worktrees.registered(checkout));
path = superproject(checkout);
}
const record = await enclosing(worktrees, input.cwd);
const agent = field(input, "agent_id");
if (record?.name === `agent-${agent}`) return record;
if (record === undefined || !WORKFLOW_CHECKOUT.test(record.name)) return undefined;
Expand All @@ -157,7 +189,9 @@ async function subagentCheckout(worktrees: Worktrees, input: HookInput): Promise
* `.worktrees/`, with derived defaults, and prints its path; an existing checkout of that name in the
* repository is restored instead.
* WorktreeRemove removes the checkout after recursive safety checks. SubagentStop releases the subagent's own
* checkout, and SubagentStart restores it, telling the subagent when its scratch was deleted.
* checkout, and SubagentStart restores it, telling the subagent when its scratch was deleted. A checkout made
* for a session rather than a subagent is held by each session that creates it or starts in it, and SessionEnd
* releases it once the last ends; SessionStart claims it again, likewise telling the session.
*/
export async function claudeHook(
input: unknown,
Expand Down Expand Up @@ -188,11 +222,14 @@ export async function claudeHook(
if (existing?.name === name) {
// Claude reuses a name, such as a resumed workflow run's, for an agent that binds afresh.
await waiting(() => worktrees.bind(existing.id));
if (!forSubagent(name)) await waiting(() => worktrees.attach(existing.id, field(hook, "session_id")));
return `${(await waiting(() => worktrees.restore(existing.id))).path}\n`;
}
const { reserve, scratch } = derivedDefaults(main);
const ref = pullRequest(cwd, name) ?? base(cwd);
return `${(await waiting(() => worktrees.create(main, name, reserve, ref, scratch))).path}\n`;
const created = await waiting(() => worktrees.create(main, name, reserve, ref, scratch));
if (!forSubagent(name)) await waiting(() => worktrees.attach(created.id, field(hook, "session_id")));
return `${created.path}\n`;
}
case "WorktreeRemove": {
const path = canonical(field(hook, "worktree_path"));
Expand All @@ -206,19 +243,32 @@ export async function claudeHook(
case "SubagentStart": {
const record = await subagentCheckout(worktrees, hook);
if (!record) return undefined;
const deleted = record.phase === "released" ? record.scratch.filter((path) => path !== WORKSPACE) : [];
await waiting(() => worktrees.restore(record.id));
if (!deleted.length) return undefined;
const additionalContext =
`Quest released this checkout while you were stopped, deleting its ignored scratch (${deleted.join(", ")}). ` +
"Reinstall or rebuild what you need before using it.";
return `${JSON.stringify({ hookSpecificOutput: { hookEventName: "SubagentStart", additionalContext } })}\n`;
return record.phase === "released" ? reinstall("SubagentStart", record.scratch) : undefined;
}
case "SubagentStop": {
const record = await subagentCheckout(worktrees, hook);
if (record) await waiting(() => worktrees.release(record.id, [WORKSPACE]));
return undefined;
}
case "SessionStart": {
const session = field(hook, "session_id");
// Resuming starts in the directory Claude was launched from before it enters the session's checkout.
const ids = new Set((await waiting(() => worktrees.held(session))).map((record) => record.id));
const here = await enclosing(worktrees, hook.cwd);
if (here?.sessions) ids.add(here.id);
const deleted = new Set<string>();
for (const id of ids) {
const before = await waiting(() => worktrees.attach(id, session));
// A failure means a release may have deleted part of the scratch before it failed or was cut short.
if (before.phase === "released" || before.failure) for (const item of before.scratch) deleted.add(item);
}
return reinstall("SessionStart", [...deleted]);
}
case "SessionEnd":
// `/clear` ends the session only to start another in the same checkout, which claims it then.
await worktrees.detach(field(hook, "session_id"), hook.reason !== "clear", [WORKSPACE]);
return undefined;
default:
throw new Error(`unsupported hook event: ${hook.hook_event_name}`);
}
Expand Down
Loading
Loading