Skip to content

feat: add agent lifecycle hooks#81

Open
chmouel wants to merge 1 commit into
mainfrom
agent-hooks
Open

feat: add agent lifecycle hooks#81
chmouel wants to merge 1 commit into
mainfrom
agent-hooks

Conversation

@chmouel

@chmouel chmouel commented Jul 12, 2026

Copy link
Copy Markdown
Owner

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:

lazyworktree setup-hooks

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-hooks with --dry-run support.

  • Installs user-level hooks into:

    • Claude Code: ~/.claude/settings.json
    • Codex CLI: $CODEX_HOME/hooks.json or ~/.codex/hooks.json
    • Copilot CLI: $COPILOT_HOME/hooks/lazyworktree.json or ~/.copilot/hooks/lazyworktree.json
  • Preserves existing hook configuration, creates timestamped backups, repairs stale lazyworktree entries, and remains idempotent across repeated runs.

  • Adds the hidden agent-event shim 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.
    • Claude MCP Elicitation and ElicitationResult receive the same treatment.
  • Keeps ordering-sensitive Claude state hooks synchronous so events cannot arrive out of order.

  • Makes the former ps/lsof process scan deprecated and disabled by default. It remains available through:

    agent_sessions:
      process_scan: true

Agent-specific notes

  • Codex CLI requires hook approval through /hooks after installation.
  • Copilot CLI loads hook files at startup, so existing sessions must be restarted.
  • Codex currently exposes no equivalent question-dialog hook, so waiting-for-question state is available for Claude Code and Copilot CLI only.
  • Hook-based Pi integration will be added later; existing transcript-based Pi support is unchanged.
  • Hook PID probes remain enabled; only the broad process-table scan became opt-in.

Safety and compatibility

  • Hook payloads are size-limited and written atomically to an owner-only state directory.
  • The shim always exits successfully so tracking failures cannot interrupt an agent session.
  • Unix and Windows process/PID handling are implemented separately.
  • Existing Claude, Codex, and Copilot hook entries are merged rather than replaced.

Documentation

Updates the CLI reference, configuration reference, AI integration guide, man page, internal help, and generated documentation. Adds a dedicated setup-hooks guide.

Testing

  • make sanity
  • make docs-check

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.
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add lifecycle hooks for Claude Code, Codex CLI, and Copilot CLI session tracking

✨ Enhancement 📝 Documentation 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Adds lazyworktree setup-hooks to install lifecycle hooks into Claude Code, Codex CLI, and
 Copilot CLI configs, with --dry-run, idempotent merging, and timestamped backups.
• Adds a hidden agent-event shim and atomic event spool consumed by the TUI watcher, synthesizing
 Codex/Copilot sessions and authoritative PID liveness without process-table scanning.
• Deprecates the ps/lsof process scan (now opt-in via agent_sessions.process_scan) and tracks
 question-dialog state (waiting/thinking) for Claude Code and Copilot CLI.
• Updates CLI/config reference docs, AI integration guide, man page, and internal help; adds
 dedicated setup-hooks guide and extensive unit tests.
Diagram

sequenceDiagram
    participant Agent as Agent CLI
    participant Hook as Lifecycle Hook
    participant Shim as agent-event shim
    participant Spool as Event Spool
    participant HookSvc as AgentHookService
    participant SessSvc as AgentSessionService
    participant TUI as TUI Watcher

    Agent->>Hook: SessionStart/Stop/etc
    Hook->>Shim: invoke with payload
    Shim->>Spool: write event atomically
    TUI->>Spool: fsnotify signal
    SessSvc->>HookSvc: Drain and States
    HookSvc->>Spool: read and remove files
    SessSvc->>SessSvc: synthesize or update session
    SessSvc->>TUI: refreshed sessions with liveness
Loading
High-Level Assessment

The hook-based approach is the right strategic choice: it replaces expensive/unreliable ps/lsof polling with authoritative, low-cost, event-driven state and unlocks Codex/Copilot visibility without depending on unstable transcript formats. Keeping the legacy scan as an opt-in fallback preserves compatibility for environments where hooks cannot be installed.

Files changed (36) +2636 / -66

Enhancement (12) +1212 / -55
app.goWire hook service into app model; make process scan opt-in +12/-2

Wire hook service into app model; make process scan opt-in

• Adds 'AgentHookService' to services state, attaches it to 'AgentSessionService', watches the hook spool directory, and only enables the deprecated 'AgentProcessService' when 'agent_sessions.process_scan' is true.

internal/app/app.go

app_agents.goThrottle process scans when hook sessions are live; add Codex marker/title +37/-44

Throttle process scans when hook sessions are live; add Codex marker/title

• Skips frequent ps/lsof refreshes when hooks provide live PID coverage, adds a Codex session marker/title, and removes rendering of the old technical liveness badge.

internal/app/app_agents.go

agent_hooks.goAdd AgentHookService and hook payload parsing/spooling +371/-0

Add AgentHookService and hook payload parsing/spooling

• Implements hook payload parsing and normalization (including question lifecycle), ancestor PID resolution, atomic spool writing, spool draining, per-session state accumulation, pruning, and PID liveness checks.

internal/app/services/agent_hooks.go

agent_processes.goClassify Codex and Copilot processes in deprecated process scan +63/-3

Classify Codex and Copilot processes in deprecated process scan

• Extends process classification to recognize Codex and Copilot CLIs (including node/npm wrappers) and normalizes Windows '.exe' suffixes.

internal/app/services/agent_processes.go

agent_registry.goDerive titles for Codex and Copilot sessions +6/-0

Derive titles for Codex and Copilot sessions

• Adds title derivation for Codex and Copilot sessions alongside existing Claude/pi behavior.

internal/app/services/agent_registry.go

agent_sessions.goDrain hook spool during refresh and integrate hook-backed sessions +30/-6

Drain hook spool during refresh and integrate hook-backed sessions

• Plumbs hook draining into the refresh pipeline, applies hook-based session synthesis and liveness overrides, and updates registry fallback merging to handle hook-backed sessions without transcript paths.

internal/app/services/agent_sessions.go

agent_sessions_hooks.goImplement hook-based session synthesis and liveness overrides +158/-0

Implement hook-based session synthesis and liveness overrides

• Adds matching logic (by transcript path or session ID), synthesizes sessions for hook-only agents, and overrides liveness/status/activity using cheap PID probes and authoritative SessionEnd semantics.

internal/app/services/agent_sessions_hooks.go

bootstrap.goRegister setup-hooks and hidden agent-event commands +2/-0

Register setup-hooks and hidden agent-event commands

• Adds the new 'setup-hooks' and hidden 'agent-event' commands to the CLI bootstrap command list.

internal/bootstrap/bootstrap.go

commands_agent.goAdd setup-hooks installer and agent-event shim command implementations +64/-0

Add setup-hooks installer and agent-event shim command implementations

• Defines the hidden 'agent-event' shim that records hook payloads without failing agents, and the 'setup-hooks' command that runs the installer with '--dry-run' support.

internal/bootstrap/commands_agent.go

agenthooks.goImplement hook installer with merge/repair/backup and dry-run +428/-0

Implement hook installer with merge/repair/backup and dry-run

• Installs and repairs lazyworktree hook entries for Claude settings, Codex hooks, and Copilot hook files; preserves foreign hooks, writes timestamped backups, supports dry-run previews, and handles quoting/executable selection.

internal/commands/agenthooks.go

agent_hook_event.goAdd normalized AgentHookEvent schema and constants +33/-0

Add normalized AgentHookEvent schema and constants

• Defines the v1 hook event schema written by the shim and shared event name constants used across supported agents.

internal/models/agent_hook_event.go

agent_session.goAdd Codex/Copilot agent kinds, hook liveness source, and PID field +8/-0

Add Codex/Copilot agent kinds, hook liveness source, and PID field

• Extends AgentKind to include Codex and Copilot, adds 'hook' as a liveness source, and adds a PID field on sessions to store hook-reported process IDs.

internal/models/agent_session.go

Bug fix (1) +1 / -1
agent_watch.goTrigger watcher refreshes on .json spool events +1/-1

Trigger watcher refreshes on .json spool events

• Extends the watcher filter to treat '.json' files (hook events) as refresh triggers in addition to '.jsonl' transcripts.

internal/app/services/agent_watch.go

Tests (7) +1143 / -5
app_agents_test.goAdjust suspect-session rendering expectations +6/-3

Adjust suspect-session rendering expectations

• Updates the test to ensure suspect sessions remain visible while technical liveness source badges (e.g., CWD) stay hidden.

internal/app/app_agents_test.go

agent_hooks_test.goAdd tests for hook parsing, spooling, PID ancestry, and state pruning +315/-0

Add tests for hook parsing, spooling, PID ancestry, and state pruning

• Covers payload parsing for Claude/Codex/Copilot, question lifecycle normalization, ancestor PID matching, draining/removal of spool files, and stale/ended state pruning.

internal/app/services/agent_hooks_test.go

agent_processes_test.goAdd Codex parsing and Copilot classification tests +29/-2

Add Codex parsing and Copilot classification tests

• Updates ps parsing expectations to include Codex and adds a dedicated test table for Copilot process classification scenarios.

internal/app/services/agent_processes_test.go

agent_sessions_hooks_test.goAdd tests for hook session synthesis, registry restore, and question lifecycle +356/-0

Add tests for hook session synthesis, registry restore, and question lifecycle

• Validates Codex/Copilot session synthesis, persistence/restore via registry, SessionEnd behavior, waiting→thinking transitions for question lifecycle events, and dead-PID handling.

internal/app/services/agent_sessions_hooks_test.go

agent_watch_test.goEnsure watcher sees first hook event after EnsureDir +34/-0

Ensure watcher sees first hook event after EnsureDir

• Adds a regression test ensuring the watcher emits a refresh signal for the first hook event even when the spool directory was newly created.

internal/app/services/agent_watch_test.go

agenthooks_test.goAdd comprehensive tests for hook installation behavior +383/-0

Add comprehensive tests for hook installation behavior

• Covers installation across agents, idempotency, preservation of existing config, dry-run behavior, env var path overrides, stale hook repair, and command detection logic.

internal/commands/agenthooks_test.go

config_test.goTest process_scan parsing and defaults +20/-0

Test process_scan parsing and defaults

• Adds config parsing tests asserting 'process_scan' opt-in works and defaults to false.

internal/config/config_test.go

Documentation (10) +151 / -4
commands.mdAdd setup-hooks to generated CLI command list +9/-0

Add setup-hooks to generated CLI command list

• Adds the 'setup-hooks' command entry and documents its '--dry-run' flag in the generated CLI commands page.

docs/cli/commands.md

flags.mdDocument setup-hooks flags +6/-0

Document setup-hooks flags

• Adds 'setup-hooks' flag documentation for '--dry-run'.

docs/cli/flags.md

setup-hooks.mdAdd dedicated setup-hooks guide +77/-0

Add dedicated setup-hooks guide

• Introduces a new guide explaining what the hook installer does, where it writes, safety/idempotency guarantees, and agent-specific notes (Codex approval, Copilot restart).

docs/cli/setup-hooks.md

configuration.mdAdd deprecated process_scan option to agent_sessions docs +2/-0

Add deprecated process_scan option to agent_sessions docs

• Documents 'agent_sessions.process_scan' as an opt-in fallback for ps/lsof scanning and updates the example config snippet.

docs/configuration.md

reference.mdUpdate generated config reference for process_scan +1/-1

Update generated config reference for process_scan

• Extends the 'agent_sessions' reference description to include 'process_scan' (deprecated, default false).

docs/configuration/reference.md

ai-integration.mdDocument hook liveness source and setup-hooks workflow +28/-0

Document hook liveness source and setup-hooks workflow

• Adds 'hook' as a liveness source and a new section describing precise, low-cost session tracking via lifecycle hooks and 'setup-hooks'.

docs/guides/ai-integration.md

main.goInclude setup-hooks and process_scan in docs generation +2/-2

Include setup-hooks and process_scan in docs generation

• Registers 'setupHooksCommand' for command parsing and updates the config key description for 'agent_sessions' to mention 'process_scan'.

hack/docsync/main.go

help.goUpdate help text for Codex navigation and setup-hooks +2/-1

Update help text for Codex navigation and setup-hooks

• Expands agent navigation help to include Codex and adds guidance to run 'lazyworktree setup-hooks' for precise tracking and question-waiting status.

internal/app/screen/help.go

lazyworktree.1Document setup-hooks in the man page +23/-0

Document setup-hooks in the man page

• Adds a man page section for 'setup-hooks', including synopsis, behavior, safety notes, and the '--dry-run' option.

lazyworktree.1

mkdocs.ymlAdd setup-hooks page to MkDocs navigation +1/-0

Add setup-hooks page to MkDocs navigation

• Includes the new 'cli/setup-hooks.md' page in the docs nav.

mkdocs.yml

Other (6) +129 / -1
go.modAdd golang.org/x/sys as a direct dependency +1/-1

Add golang.org/x/sys as a direct dependency

• Promotes 'golang.org/x/sys' to a direct requirement to support Windows process APIs used by hook PID resolution/liveness checks.

go.mod

agent_hooks_unix.goUnix hook PID resolution and liveness probe +45/-0

Unix hook PID resolution and liveness probe

• Resolves agent PID by walking the process tree using 'ps', and checks liveness via 'syscall.Kill(pid, 0)'.

internal/app/services/agent_hooks_unix.go

agent_hooks_windows.goWindows hook PID resolution and liveness probe +60/-0

Windows hook PID resolution and liveness probe

• Resolves agent PID by scanning process snapshots (Toolhelp32) and checks liveness with 'OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)'.

internal/app/services/agent_hooks_windows.go

agenthooks_unix.goUnix executable quoting for hook commands +9/-0

Unix executable quoting for hook commands

• Uses the multiplexer shell-quoting helper to safely embed the executable path in installed hook commands on Unix.

internal/commands/agenthooks_unix.go

agenthooks_windows.goWindows executable quoting for hook commands +9/-0

Windows executable quoting for hook commands

• Implements Windows-safe quoting for executable paths used in installed hook commands.

internal/commands/agenthooks_windows.go

config.goAdd agent_sessions.process_scan opt-in configuration +5/-0

Add agent_sessions.process_scan opt-in configuration

• Introduces 'AgentProcessScan' (deprecated), parses 'agent_sessions.process_scan', and supports CLI overrides for the new nested key.

internal/config/config.go

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (1) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 29 rules

Grey Divider


Action required

1. README missing setup-hooks update 📘 Rule violation ✧ Quality
Description
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.
Code

internal/bootstrap/commands_agent.go[R45-62]

+// 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,
+			})
+		},
Evidence
The compliance rule requires README updates for user-facing behavior changes. The PR introduces a
new user-facing CLI command setup-hooks for installing hooks (and references Codex/Copilot
support), while the README's feature list remains outdated and does not mention the new command or
expanded agent support.

Rule 349624: Update README for any user-facing behavior change
internal/bootstrap/commands_agent.go[45-62]
README.md[18-32]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## 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



Remediation recommended

2. Spool filename collisions 🐞 Bug ☼ Reliability
Description
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.
Code

internal/app/services/agent_hooks.go[R154-165]

+// 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)
+}
Evidence
The spool filename is fully determined by timestamp/agent/event name, and the atomic writer commits
via os.Rename to that destination path; therefore a same-name collision can drop an event via
overwrite or rename error depending on platform behavior.

internal/app/services/agent_hooks.go[154-165]
internal/app/services/agent_registry.go[225-248]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### 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


3. Watcher triggers on all .json 🐞 Bug ➹ Performance
Description
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.
Code

internal/app/services/agent_watch.go[R154-160]

			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()
Evidence
The watcher now signals on .json and is configured to watch both transcript roots and the hook
spool directory, so any .json activity under transcript roots can now trigger refreshes.

internal/app/services/agent_watch.go[154-160]
internal/app/app.go[605-617]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### 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


4. Empty config breaks setup 🐞 Bug ☼ Reliability
Description
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).
Code

internal/commands/agenthooks.go[R137-143]

+	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)
+		}
Evidence
Both hook installers call json.Unmarshal whenever ReadFile succeeds, with no special-casing for
empty content, so an empty existing file will cause setup-hooks to error out at parse time.

internal/commands/agenthooks.go[137-148]
internal/commands/agenthooks.go[173-185]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### 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


Grey Divider

Qodo Logo

Comment on lines +45 to +62
// 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,
})
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

Comment on lines +154 to +165
// 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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

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

Comment on lines 154 to 160
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

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

Comment on lines +137 to +143
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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +252 to +270
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())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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())

Comment on lines +54 to +58
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return false
}
_ = windows.CloseHandle(handle)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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)

Comment on lines +221 to +223
if err := os.WriteFile(path, updated, 0o600); err != nil {
return fmt.Errorf("%s: write %s: %w", label, path, err)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
for range 8 {
for range 32 {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant