feat: add agent lifecycle hooks#81
Conversation
Added lifecycle hook installation for Claude Code, Codex CLI, and GitHub Copilot CLI so session tracking no longer depended on costly process-table scans. Made ps/lsof discovery deprecated and opt-in. Hook events now tracked exact liveness, question prompts, and Copilot sessions.
PR Summary by QodoAdd lifecycle hooks for Claude Code, Codex CLI, and Copilot CLI session tracking
AI Description
Diagram
High-Level Assessment
Files changed (36)
|
Code Review by Qodo
Context used✅ Compliance rules (platform):
29 rules 1. README missing setup-hooks update
|
| // setupHooksCommand installs the agent-event shim into the Claude Code, | ||
| // Codex CLI, and Copilot CLI hook configurations. | ||
| func setupHooksCommand() *appiCli.Command { | ||
| return &appiCli.Command{ | ||
| Name: "setup-hooks", | ||
| Usage: "Install agent session hooks for Claude Code, Codex CLI, and Copilot CLI", | ||
| Flags: []appiCli.Flag{ | ||
| &appiCli.BoolFlag{ | ||
| Name: "dry-run", | ||
| Usage: "Show the changes without writing any files", | ||
| }, | ||
| }, | ||
| Action: func(_ context.Context, cmd *appiCli.Command) error { | ||
| return commands.SetupAgentHooks(commands.SetupAgentHooksOptions{ | ||
| DryRun: cmd.Bool("dry-run"), | ||
| Stdout: os.Stdout, | ||
| }) | ||
| }, |
There was a problem hiding this comment.
1. Readme missing setup-hooks update 📘 Rule violation ✧ Quality
The PR adds the new user-facing setup-hooks command for installing Claude/Codex/Copilot lifecycle hooks, but README still describes agent sessions as only "Claude and pi" and does not mention the new hook-based setup path. This can mislead users and violates the requirement to update README for user-facing behavior changes.
Agent Prompt
## Issue description
A new user-facing CLI command `lazyworktree setup-hooks` was introduced, and agent session support now includes Claude Code, Codex CLI, and Copilot CLI via lifecycle hooks, but `README.md` was not updated and still describes agent sessions as only Claude and pi.
## Issue Context
`setup-hooks` is the primary way users enable the new hook-based session tracking, so README should mention it (at least in Main Features and/or a short snippet pointing to the docs page).
## Fix Focus Areas
- README.md[18-52]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| // WriteAgentHookEvent atomically writes one event into the spool directory. | ||
| func WriteAgentHookEvent(dir string, event models.AgentHookEvent) error { | ||
| if err := os.MkdirAll(dir, 0o700); err != nil { | ||
| return fmt.Errorf("create spool dir: %w", err) | ||
| } | ||
| data, err := json.Marshal(event) | ||
| if err != nil { | ||
| return fmt.Errorf("encode hook event: %w", err) | ||
| } | ||
| name := fmt.Sprintf("%020d-%s-%s.json", event.Timestamp.UnixNano(), event.Agent, sanitizeHookFileComponent(event.HookEventName)) | ||
| return writeAtomically(filepath.Join(dir, name), data) | ||
| } |
There was a problem hiding this comment.
2. Spool filename collisions 🐞 Bug ☼ Reliability
WriteAgentHookEvent derives the destination spool filename only from UnixNano timestamp + agent + event name, so two same-agent events with the same event name emitted in the same nanosecond can target the same path and result in one event being lost (platform-dependent overwrite vs rename failure). Losing a lifecycle event can leave the synthesized session state incorrect until the next event arrives.
Agent Prompt
### Issue description
`WriteAgentHookEvent` uses a deterministic filename based on `event.Timestamp.UnixNano()`, `event.Agent`, and `event.HookEventName`. If two events collide on the same nanosecond for the same agent/event name, they can attempt to write to the same destination path, causing one event to be overwritten or the atomic rename to fail (depending on OS semantics). This can drop lifecycle events and corrupt session state.
### Issue Context
The spool writer uses `writeAtomically(..., os.Rename)` to commit the final file. Adding a collision-resistant suffix (while preserving timestamp ordering) avoids event loss.
### Fix Focus Areas
- internal/app/services/agent_hooks.go[154-165]
- internal/app/services/agent_registry.go[225-248]
### Suggested change
Keep the timestamp prefix for sort order, but include an additional uniqueness component in the filename (e.g., PID + monotonic counter, or a random hex suffix). For example:
- `name := fmt.Sprintf("%020d-%s-%s-%d-%08x.json", ts, agent, event, pid, rand)`
Also consider adding a unit test that writes two events with identical timestamps/agent/event name and asserts both spool files exist.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| if event.Op&fsnotify.Create != 0 { | ||
| w.maybeWatchNewDir(event.Name) | ||
| } | ||
| if !strings.HasSuffix(event.Name, ".jsonl") { | ||
| if !strings.HasSuffix(event.Name, ".jsonl") && !strings.HasSuffix(event.Name, ".json") { | ||
| continue | ||
| } | ||
| w.signal() |
There was a problem hiding this comment.
3. Watcher triggers on all .json 🐞 Bug ➹ Performance
AgentWatchService now signals refreshes for any .json change in any watched root, not just .jsonl transcripts; because the watcher roots include transcript roots plus the hook spool dir, unrelated JSON writes under transcript roots can trigger expensive full refreshes. This increases background CPU/IO and undermines the goal of making hook-based tracking low-cost.
Agent Prompt
### Issue description
The watcher now treats both `.jsonl` and `.json` files as refresh triggers. This is needed for hook spool events (`*.json`), but it also applies to *all* other watched roots (Claude/pi transcript roots), which can contain other JSON files. That broadening can cause unnecessary refresh churn.
### Issue Context
`NewModel` adds the hook spool dir to the same watcher roots list as transcript roots. The watcher does not distinguish which root produced the event.
### Fix Focus Areas
- internal/app/services/agent_watch.go[154-160]
- internal/app/app.go[605-617]
### Suggested change
Limit `.json` triggers to the hook spool directory only. Options:
1) Add a dedicated watcher (or dedicated root list) for the spool directory that triggers on `.json`, while keeping transcript roots limited to `.jsonl`.
2) Teach `AgentWatchService` about which roots allow `.json` and check `event.Name` against those roots before signaling.
Add/adjust tests to ensure spool `.json` still triggers, while unrelated `.json` under transcript roots does not.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| root := map[string]any{} | ||
| data, err := os.ReadFile(path) //nolint:gosec // User-level config path. | ||
| switch { | ||
| case err == nil: | ||
| if err := json.Unmarshal(data, &root); err != nil { | ||
| return fmt.Errorf("%s: parse %s: %w", label, path, err) | ||
| } |
There was a problem hiding this comment.
4. Empty config breaks setup 🐞 Bug ☼ Reliability
setup-hooks fails when a target config file exists but is empty/whitespace because it always attempts json.Unmarshal after a successful ReadFile and returns an error on parse failure. This prevents hook installation for users with placeholder/empty config files (or truncated files after an interrupted write).
Agent Prompt
### Issue description
`installHooksFile` and `installCopilotHooksFile` unconditionally `json.Unmarshal` file contents when `os.ReadFile` succeeds. If the file exists but is empty/whitespace-only, setup aborts with a parse error instead of treating it as an empty configuration.
### Issue Context
This affects the new `lazyworktree setup-hooks` command and can block users from enabling hook-based session tracking.
### Fix Focus Areas
- internal/commands/agenthooks.go[137-148]
- internal/commands/agenthooks.go[173-185]
### Suggested change
Before unmarshalling, check `len(bytes.TrimSpace(data))`:
- If empty: skip unmarshal and treat `root` as `{}` (but keep `previous` bytes so backup behavior remains consistent).
- Otherwise: unmarshal as today.
Add unit tests for both config formats (Claude/Codex and Copilot) covering zero-byte and whitespace-only files.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
There was a problem hiding this comment.
Code Review
This pull request introduces precise, low-cost agent session tracking using lifecycle hooks for Claude Code, Codex CLI, and Copilot CLI, adding a new setup-hooks command to install these hooks and a hidden agent-event command to spool events. This replaces the deprecated process-table scanning method. Key feedback includes optimizing AgentHookService.Drain() by moving disk I/O outside of the mutex lock to prevent UI stuttering, correctly handling windows.ERROR_ACCESS_DENIED in Windows process liveness checks, ensuring atomic writes when updating configuration files to prevent corruption, and increasing the process ancestor search depth to accommodate complex developer environments.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| s.mu.Lock() | ||
| defer s.mu.Unlock() | ||
| for _, name := range names { | ||
| path := filepath.Join(s.dir, name) | ||
| data, err := os.ReadFile(path) //nolint:gosec // Spool path is app-controlled. | ||
| if err != nil { | ||
| s.logf("agent hooks: read %s: %v", name, err) | ||
| continue | ||
| } | ||
| var event models.AgentHookEvent | ||
| if err := json.Unmarshal(data, &event); err != nil { | ||
| s.logf("agent hooks: decode %s: %v", name, err) | ||
| s.removeSpoolFile(path, name) | ||
| continue | ||
| } | ||
| s.applyLocked(event) | ||
| s.removeSpoolFile(path, name) | ||
| } | ||
| s.pruneLocked(time.Now()) |
There was a problem hiding this comment.
Holding the mutex lock s.mu.Lock() while performing disk I/O (os.ReadFile and os.Remove) in Drain() can block the UI thread. The UI thread calls hooks.HasLiveSessions() which also acquires s.mu.Lock(). If disk I/O is slow, this will cause the Bubble Tea TUI to stutter or freeze.
Consider reading and parsing the files outside the lock, collecting the valid events, and then acquiring the lock only to apply the events and prune.
events := make([]models.AgentHookEvent, 0, len(names))
for _, name := range names {
path := filepath.Join(s.dir, name)
data, err := os.ReadFile(path) //nolint:gosec // Spool path is app-controlled.
if err != nil {
s.logf("agent hooks: read %s: %v", name, err)
continue
}
var event models.AgentHookEvent
if err := json.Unmarshal(data, &event); err != nil {
s.logf("agent hooks: decode %s: %v", name, err)
s.removeSpoolFile(path, name)
continue
}
events = append(events, event)
s.removeSpoolFile(path, name)
}
s.mu.Lock()
defer s.mu.Unlock()
for _, event := range events {
s.applyLocked(event)
}
s.pruneLocked(time.Now())| handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) | ||
| if err != nil { | ||
| return false | ||
| } | ||
| _ = windows.CloseHandle(handle) |
There was a problem hiding this comment.
In agentHookPIDAlive, if windows.OpenProcess fails with windows.ERROR_ACCESS_DENIED, it means the process exists but the current user doesn't have permissions to query it. Returning false in this case incorrectly reports the process as dead.
Check if the error is windows.ERROR_ACCESS_DENIED and return true in that case.
| handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) | |
| if err != nil { | |
| return false | |
| } | |
| _ = windows.CloseHandle(handle) | |
| handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) | |
| if err != nil { | |
| return err == windows.ERROR_ACCESS_DENIED | |
| } | |
| _ = windows.CloseHandle(handle) |
| if err := os.WriteFile(path, updated, 0o600); err != nil { | ||
| return fmt.Errorf("%s: write %s: %w", label, path, err) | ||
| } |
There was a problem hiding this comment.
Writing the updated agent configuration file directly using os.WriteFile can corrupt the file if the write fails halfway (e.g., due to disk full or power loss).
Write to a temporary file in the same directory and then rename it to the target path to ensure an atomic write.
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, updated, 0o600); err != nil {
return fmt.Errorf("%s: write temp %s: %w", label, tmpPath, err)
}
if err := os.Rename(tmpPath, path); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("%s: rename %s to %s: %w", label, tmpPath, path, err)
}|
|
||
| func findAgentAncestorPID(agent models.AgentKind, startPID int, lookup agentHookProcessLookup) int { | ||
| pid := startPID | ||
| for range 8 { |
There was a problem hiding this comment.
The process ancestor search in findAgentAncestorPID is limited to exactly 8 levels. In complex developer environments (e.g., nested shells, direnv, nix-shell, tmux, wrappers), the process tree can easily exceed 8 levels, causing hook PID resolution to fail.
Increase the ancestor search limit to a more robust value like 32.
| for range 8 { | |
| for range 32 { |
Summary
Adds lifecycle-hook-based agent session tracking for Claude Code, OpenAI Codex CLI, and GitHub Copilot CLI. Hooks provide authoritative session state and inexpensive PID liveness checks, replacing periodic process-table discovery as the default.
Release note
Users are now encouraged to run the following command after installing or upgrading lazyworktree:
Hook-based tracking is now the recommended way to obtain accurate, low-cost agent session status. The command currently configures Claude Code, Codex CLI, and Copilot CLI. Pi hook integration is intentionally deferred and will follow in a later release; existing Pi transcript support remains unchanged.
Changes
Adds
lazyworktree setup-hookswith--dry-runsupport.Installs user-level hooks into:
~/.claude/settings.json$CODEX_HOME/hooks.jsonor~/.codex/hooks.json$COPILOT_HOME/hooks/lazyworktree.jsonor~/.copilot/hooks/lazyworktree.jsonPreserves existing hook configuration, creates timestamped backups, repairs stale lazyworktree entries, and remains idempotent across repeated runs.
Adds the hidden
agent-eventshim and an atomic event spool consumed by the TUI watcher.Synthesises Codex and Copilot sessions directly from hook events, without depending on unstable transcript formats.
Adds Copilot CLI process classification and session presentation.
Tracks question-dialog state for Claude Code and Copilot CLI:
PreToolUse(AskUserQuestion)reports waiting.PostToolUse(AskUserQuestion)reports thinking after an answer.ElicitationandElicitationResultreceive the same treatment.Keeps ordering-sensitive Claude state hooks synchronous so events cannot arrive out of order.
Makes the former
ps/lsofprocess scan deprecated and disabled by default. It remains available through:Agent-specific notes
/hooksafter installation.Safety and compatibility
Documentation
Updates the CLI reference, configuration reference, AI integration guide, man page, internal help, and generated documentation. Adds a dedicated
setup-hooksguide.Testing
make sanitymake docs-check