# Hooks

projmux runs optional user scripts at selected tmux lifecycle points. Hooks are
the project-agnostic extension point for behavior projmux itself stays out of:
injecting per-session env via `tmux set-environment`, selecting repository
tokens, exporting a Kubernetes context, kicking off a background sync, or
sending an initial pane command.

projmux-owned internal tmux hooks such as `pane-focus-in`, `pane-focus-out`,
`after-select-pane`, and `after-kill-pane` are not exposed as user hook events.

## Events

| Event | When it runs | Failure behavior | Stdout behavior |
| --- | --- | --- | --- |
| `pre-create` | Before projmux creates a missing persistent or ephemeral session | Non-zero exit, exec error, or timeout aborts creation | Logged with `[pre-create] ` |
| `post-create` | After projmux creates a brand-new persistent or ephemeral session | Logged and ignored; creation continues | Logged with `[post-create] ` |
| `post-attach` | After projmux switches the current tmux client to an existing session/target from inside tmux | Logged and ignored | Logged with `[post-attach] ` |
| `send-noti` | After `projmux notify push` (or the in-process AI notify producer) successfully writes a queue entry | Fired asynchronously and best-effort; queue write and desktop notifications continue even if the hook fails or times out | Receives JSON on stdin; stdout/stderr are logged with `[send-noti] ` |

Deferred Phase A candidates remain future work until their behavior can be
specified without exposing projmux's internal tmux hook machinery: pane exit,
window create/rename, and focus-change hook events.

## Where Hooks Live

Global hooks live in the XDG config file:

```text
${XDG_CONFIG_HOME:-$HOME/.config}/projmux/config.toml
```

Project-local hooks are discovered from the lifecycle context's `PROJMUX_CWD`:

```text
<repo>/.projmux/config.toml
```

File-form hooks from the historical `.projmux/<event>` and
`.projmux/hooks/<event>` layouts are no longer executed. Use declarative
`[hooks.<event>] run` entries instead.

If both a global hook and a project-local hook exist for an event, projmux runs
the global hook first, then the project-local hook.

## Project Config

Repositories may declare hook behavior in `.projmux/config.toml`. projmux
supports only this narrow TOML subset in Phase B:

```toml
[startup]
run = "git status --short"

[hooks.pre-create]
run = "echo checking"

[hooks.post-create]
run = "echo created $PROJMUX_SESSION"

[hooks.post-attach]
run = "echo attached"

[hooks.send-noti]
run = "jq -r '.message' | xargs -I{} send-slack \"{}\""

[env]
FOO = "bar"

[kube]
context = "dev-cluster"
namespace = "tools"
```

Only quoted string values are supported. Unknown sections or keys make the
config file invalid for this run. Supported hook events are the public lifecycle
events listed above: `pre-create`, `post-create`, `post-attach`, and
`send-noti`. Internal tmux hook names such as
`after-select-pane` are rejected. Phase C secret interpolation is not
implemented; values are used literally.

`[hooks.<event>] run` executes through `sh -c` with the same timeout, logging,
environment, and failure model for all hook events.

`[hooks.send-noti] run` is the declarative forward path for queue-backed
notifications. It does not replace desktop notifications or the in-app queue;
it runs in parallel after the queue write succeeds so users can fan out to
Slack, webhooks, or custom scripts without changing the built-in notification
path.

`[startup] run` is a direct shorthand for a startup pane command. It is not
executed as shell by the hook runner; the string itself is sent to the new pane.

`[env]` values are added to hook process environments and to newly-created tmux
session environments. For new sessions, projmux passes them to
`tmux new-session` as sorted `-e KEY=VALUE` arguments before the first pane is
created, so the initial shell and `[startup]` command can see them. projmux also
refreshes the tmux session environment with `tmux set-environment -t <session>
KEY VALUE` after creation for later panes. projmux's reserved `PROJMUX_*` hook
variables are appended after `[env]` in hook process environments, so the hook
contract cannot be overridden by project config.

`[kube] context` and `namespace` are reflected as hook and session environment:
`PROJMUX_KUBE_CONTEXT`, `KUBE_CONTEXT`, `PROJMUX_KUBE_NAMESPACE`, and
`KUBE_NAMESPACE`. projmux does not synthesize kubeconfig files from these two
values.

## Trust Model

Global hooks under `$XDG_CONFIG_HOME` are prompt-free.

Project-local `.projmux/config.toml` files are gated by trust-on-first-use
before projmux runs hooks or applies startup/session environment settings.
Approving "always"
records the file content hash in:

```text
${XDG_STATE_HOME:-$HOME/.local/state}/projmux/trusted-projects.json
```

The trust key is the absolute repository path and the config path is stored
relative to that repository. Each project entry has a
`trusted_at` timestamp and a `files` map of relative paths to SHA-256 hashes.
When file content changes, projmux asks again and shows the old and new SHA-256
hashes. In non-interactive contexts such as tmux run-shell or CI, untrusted or
changed project-local files fail closed with a warning.

Set `PROJMUX_PROJECT_HOOKS=off` to disable project-local hook discovery
entirely. Project-local hooks can also be disabled from `projmux settings`
under Labs. The global hook still runs either way.

## Startup Commands

`[startup] run` applies only to the initial pane created with a new
projmux-managed session. It runs after `post-create` so `post-create` can seed
session-level tmux environment before the startup command is sent. It does not
run when projmux attaches to an existing session or target.

Before sending the startup command, projmux polls tmux `pane_current_command`
for the new pane and waits until it reports a shell command. This avoids fixed
sleeps as the primary readiness mechanism.

The configured string is sent into the new pane:

```text
tmux send-keys -t <pane-id> <command> Enter
```

An empty `[startup] run` is a no-op.

## Send Noti

`send-noti` fires only after the notify queue write succeeds. The queue entry is
already durable before the hook starts, so a failing or slow hook cannot drop
the notification or block the normal desktop notification flow.

The hook runs asynchronously with the same default timeout (`5s`) as other
hooks. projmux does not wait for completion before returning from
`projmux notify push`.

stdin receives one JSON object:

```json
{
  "event": "send-noti",
  "id": "ai:main:%9",
  "type": "ai-reply-ready",
  "agent": "claude",
  "topic": "worker loop",
  "pane": "%9",
  "session": "main",
  "message": "worker loop",
  "metadata": {
    "agent": "claude",
    "category": "response_complete",
    "state": "need"
  },
  "created_at": "2026-05-12T02:03:04Z"
}
```

The hook environment also includes:

| Variable | Description |
| --- | --- |
| `PROJMUX_NOTIFY_ID` | Queue entry id |
| `PROJMUX_NOTIFY_TYPE` | Notification kind (`ai-reply-ready`, `external`, etc.) |
| `PROJMUX_NOTIFY_AGENT` | AI agent label when known |
| `PROJMUX_NOTIFY_TOPIC` | AI topic when known |
| `PROJMUX_NOTIFY_PANE` | Target pane id when known |
| `PROJMUX_NOTIFY_SESSION` | Target tmux session |
| `PROJMUX_NOTIFY_MESSAGE` | Human-readable message text |
| `PROJMUX_NOTIFY_HOOK_DEPTH` | Recursion guard; values `>= 1` suppress nested `send-noti` dispatch |

If a `send-noti` hook itself calls `projmux notify push`, projmux sees
`PROJMUX_NOTIFY_HOOK_DEPTH=1` in the child environment and skips another
`send-noti` hook fire. The queue write itself still succeeds.

`Settings > Notifications > Delivery sources` surfaces the active Codex hooks,
Claude, and tmux AI notify diagnostics: status, conflicts, config paths, and
copyable CLI install/remove/dry-run commands. It does not install or remove
external Codex, Claude, or tmux settings.

`PROJMUX_NOTIFY_HOOK` is separate from `[hooks.send-noti]`: it replaces the
desktop sender and receives positional arguments
`summary body urgency app-name tag group icon-path`. That `urgency` value is
the OS notification urgency, not the notify-queue severity. AI approval,
input, selection, and confirmation rows can stay critical in the queue and UI
while the desktop notification hook receives `normal`. Live AI status badges
are a third surface: permission/input-required panes use the action-required
amber-orange status role, response-complete panes use success green, and
in-progress panes use progress yellow. They do not inherit the critical queue
severity, and permission/input status badges do not use red.

## Codex Hooks Engine

`projmux doctor` reports Codex hooks-engine wiring separately from legacy
notify, including unmanaged hooks/conflict details and the relevant
`projmux ai integrate codex` commands.

`projmux ai ingest codex-hook` is the conservative core ingest path for Codex
hooks-engine events. It reads a single JSON payload from stdin. The embedded
default install catalog is based on Codex CLI 0.130.0:

| Event | Behavior |
| --- | --- |
| `PreToolUse` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `UserPromptSubmit` | marks the matched pane hook-active and sets AI state to thinking/busy; no notify queue entry is pushed |
| `PermissionRequest` | pushes a critical approval row with the tool name and a concise tool/action summary |
| `PostToolUse` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `PreCompact` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `PostCompact` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `SessionStart` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `Stop` | pushes an info Codex completion row |

Codex hook payload parsing accepts the common fields
`hook_event_name`/`event_name`, `thread_id`, `session_id`, `turn_id`, `cwd`,
`transcript_path`, `model`, `tool_name`, nested `tool.name`, `tool_input`, and
`input`. For pane matching, Codex hook ingest uses `thread_id` when available
and falls back to treating `session_id` as the thread identity so existing
`matchAIPane` matching can reuse cached pane metadata.

`projmux ai integrate codex` manages a separate
`~/.codex/config.toml` marker block for the hooks engine. If a `[features]`
table already exists, projmux merges `hooks = true` into that table instead of
creating a duplicate table. Older projmux-managed `codex_hooks = true` entries
are migrated to `hooks = true` to avoid Codex's deprecation warning:

```toml
[features]
hooks = true

[[hooks.PreToolUse]]
matcher = "*"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "projmux ai ingest codex-hook >/dev/null 2>&1 || true"

[[hooks.PermissionRequest]]
matcher = "*"
[[hooks.PermissionRequest.hooks]]
type = "command"
command = "projmux ai ingest codex-hook >/dev/null 2>&1 || true"

[[hooks.PostToolUse]]
matcher = "*"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "projmux ai ingest codex-hook >/dev/null 2>&1 || true"

[[hooks.PreCompact]]
matcher = "*"
[[hooks.PreCompact.hooks]]
type = "command"
command = "projmux ai ingest codex-hook >/dev/null 2>&1 || true"

[[hooks.PostCompact]]
matcher = "*"
[[hooks.PostCompact.hooks]]
type = "command"
command = "projmux ai ingest codex-hook >/dev/null 2>&1 || true"

[[hooks.SessionStart]]
matcher = "*"
[[hooks.SessionStart.hooks]]
type = "command"
command = "projmux ai ingest codex-hook >/dev/null 2>&1 || true"

[[hooks.UserPromptSubmit]]
matcher = "*"
[[hooks.UserPromptSubmit.hooks]]
type = "command"
command = "projmux ai ingest codex-hook >/dev/null 2>&1 || true"

[[hooks.Stop]]
matcher = "*"
[[hooks.Stop.hooks]]
type = "command"
command = "projmux ai ingest codex-hook >/dev/null 2>&1 || true"
```

Repeated installs are idempotent and preserve unrelated Codex config, including
unmanaged hook entries for the same events. If projmux sees an unmanaged
`projmux ai ingest codex-hook` command, it refuses to install over it rather
than guessing ownership. `--dry-run` previews the TOML update. `--remove`
removes projmux-managed Codex hooks wiring.

The Codex hooks install list is catalog-driven. Projmux ships an embedded
default catalog at `internal/app/ai_hook_catalogs/codex.json`, and merges an
optional local override from:

```text
${XDG_CONFIG_HOME:-$HOME/.config}/projmux/ai-hooks.d/codex.json
```

Override events replace matching embedded events by name and append new names,
so a local file can disable a stale event with `"install": false` or add a new
Codex event before projmux itself is released. Supported `action` values are
`notify`, `state`, and `quiet`; install uses only events whose effective
`install` value is true. Removal remains marker-block based rather than catalog
based, so older projmux-managed Codex hook blocks are still removed after the
catalog changes.

```json
{
  "provider": "codex",
  "events": [
    { "name": "Stop", "install": false, "action": "notify" },
    { "name": "FutureEvent", "install": true, "action": "quiet" }
  ]
}
```

Codex ingest has built-in notify/state handlers for the known reply-ready,
approval, and prompt-submit events. Events without a specialized handler,
including override-added events, are quiet/log-only after pane matching; a
catalog `"action": "quiet"` makes that quiet fallback explicit. Catalog
`"notify"` and `"state"` entries still need a built-in handler before they can
push queue rows or change pane state.

Runtime action overrides are stored separately from the install catalog at:

```text
${XDG_CONFIG_HOME:-$HOME/.config}/projmux/ai-hook-actions.json
```

`Settings > Notifications > Hook quiet policy` reads and writes that file. The
runtime file only changes ingest behavior (`notify`, `state`, or `quiet`);
`projmux ai integrate codex` still uses the catalog `install` field to decide
which hooks to write. Runtime overrides also apply to known specialized
events, so `Stop` or `PermissionRequest` can be made state-only or quiet
without changing which hook commands are installed. When a known Codex event
without a specialized handler, such as `PreToolUse` or `PostToolUse`, is set to
runtime `notify`, projmux pushes a generic in-app notify row such as
`PreToolUse · Bash` with agent/category metadata. That generic path is
queue/sidebar/statusbar only: it does not dispatch `[hooks.send-noti]`,
`PROJMUX_NOTIFY_HOOK`, `notify-send`, or Windows toast.
Generic metadata is limited to safe summary fields such as provider, event,
tool, cwd, thread, session, turn, and model; raw payloads and tool input are
not stored.

Codex may require reviewing or trusting hooks through its `/hooks` flow before
commands run. Projmux only writes the managed config block; it does not attempt
to auto-trust hooks.

## Tmux Bell Fallback

`projmux doctor` reports whether the current tmux server has a
projmux-managed bell hook installed. The diagnostic is read-only; it only
inspects current `alert-bell` hooks and prints the matching integrate commands.

`projmux ai integrate tmux-bell` is the opt-in fallback for AI CLIs that do
not expose structured hooks but do emit BEL or OSC 9. It mutates the current
tmux server only; it does not edit tmux config files. Install applies these
server settings and appends a marked `alert-bell` hook:

```text
tmux set-option -g allow-passthrough on
tmux set-option -g monitor-bell on
tmux set-option -g bell-action other
tmux set-hook -ag alert-bell run-shell -b 'projmux ai ingest bell --pane "#{pane_id}" >/dev/null 2>&1 || true # projmux-managed:tmux-bell:v1'
```

The hook calls `projmux ai ingest bell --pane <pane_id>`. Bell ingest resolves
the target pane through tmux and pushes an info notify queue row such as
`bell · Claude CLI`, with metadata including `agent=bell`, `event=bell`,
session/window/pane, pane title, command, and socket path when available. The
pane does not need `@projmux_ai_agent` or any other AI-managed option because
this path is for unknown tools. tmux `alert-bell` is a window alert; tmux 3.4
does not expose `#{hook_pane}` there, so projmux uses `#{pane_id}` as the best
available pane context.

Repeated bells from the same pane are suppressed for 5 seconds using a
pane-local tmux timestamp option. The notify row also uses a stable
`ai:bell:<session>:<pane>` id, so later non-suppressed bells refresh the same
queue entry rather than creating unbounded duplicates.

`--dry-run` prints the tmux commands without applying them. `--remove` reads
the current `alert-bell` hooks and unsets only entries carrying
`projmux-managed:tmux-bell:v1`, preserving unmanaged user hooks.

## Claude Code Hook Ingest

`projmux doctor` reports Claude Code hook wiring in `~/.claude/settings.json`,
including unmanaged projmux ingest command conflicts and the relevant
`projmux ai integrate claude` commands.

`projmux ai ingest claude-hook` is the conservative core ingest path for
Claude Code hooks. It reads a single JSON payload from stdin. The embedded
default install catalog is based on Claude Code 2.1.140 and represents the
29 hook events visible in that version:

| Event | Behavior |
| --- | --- |
| `PreToolUse` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `PostToolUse` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `PostToolUseFailure` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `PostToolBatch` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `PermissionDenied` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `Notification` | pushes a Claude notify row for response-ready, approval-required, or input-ready based on `notification_type` |
| `UserPromptSubmit` | marks the matched pane hook-active and sets AI state to thinking/busy; no notify queue entry is pushed |
| `UserPromptExpansion` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `SessionStart` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `Stop` | pushes a Claude completion row, using the last assistant transcript text when `transcript_path` is readable |
| `StopFailure` | pushes a critical Claude error row with error type/message metadata when present |
| `SubagentStart` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `SubagentStop` | marks the pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `PreCompact` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `PostCompact` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `SessionEnd` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `PermissionRequest` | pushes a critical approval row with the tool name and a concise tool input summary |
| `Setup` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `TeammateIdle` | pushes an info Claude teammate waiting row with teammate context metadata when present |
| `TaskCreated` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `TaskCompleted` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `Elicitation` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `ElicitationResult` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `ConfigChange` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `InstructionsLoaded` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `WorktreeCreate` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `WorktreeRemove` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `CwdChanged` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `FileChanged` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |

Hook-generated queue rows use the same compact body catalog: agent label,
event category, then the best available summary (Codex assistant text, Claude
tool/action summary, transcript summary, error, or teammate labels). Structured
payload details remain in `metadata`, which is passed through the `send-noti`
JSON payload and `notify list --json`; the sidebar stays compact and does not
expand a separate metadata detail view. `SubagentStop` remains parsed for
diagnostics but is intentionally excluded from hook notifications because it can
fire at high volume.

Pane matching follows the shared AI ingest order: inherited `$TMUX_PANE`, then
payload `cwd`, then cached session id pane options. A matched pane is marked
with `@projmux_ai_hook_active=1`, so `projmux ai watch-title` skips the pane
after a minimal hook-active gate instead of polling pane title/capture output;
hook payloads become the primary signal. The tmux bell fallback does not mark
panes hook-active, so title/capture fallback remains available for panes that
only emit bells.

The Claude hook payload is intentionally accepted directly at the ingest
boundary. Core identity fields accept `hook_event_name`/`event_name`,
`session_id`/`session-id`, `cwd`/`workspace`/`project_dir`, and nested
`workspace.cwd|path`. Extra-event fields are intentionally tolerant while the
upstream schemas settle:

| Event | Accepted fields |
| --- | --- |
| `StopFailure` | `error_type`, `errorType`, `failure_type`, `failureType`; `error_message`, `errorMessage`, `message`, `reason`; nested `error.type`, `error.name`, `error.code`, `error.message`, `error.text`, `error.reason` |
| `SubagentStop` | `subagent_type`, `subagentType`, `agent_type`, `agentType`; `subagent_id`, `subagentId`, `agent_id`, `agentId`; nested `subagent.type`, `subagent.name`, `subagent.kind`, `subagent.id`, `subagent.subagent_id`, `subagent.agent_id` |
| `TeammateIdle` | `teammate_name`, `teammateName`, `teammate`; `teammate_id`, `teammateId`; `teammate_context`, `teammateContext`, `context`, `reason`, `message`; nested `teammate.name`, `teammate.type`, `teammate.kind`, `teammate.id`, `teammate.teammate_id`, `teammate.context`, `teammate.status`, `teammate.reason`, `teammate.message` |

`projmux ai integrate claude` manages user-level Claude Code hook settings in
`~/.claude/settings.json`. Claude Code hooks are configured under the top-level
`hooks` object, with each event containing matcher entries and each matcher
entry containing a `hooks` array. Projmux omits `matcher`, which Claude Code
treats as "match all" for the event; this keeps `Notification` notification
types and `PermissionRequest` tool names broad while this integration remains
an observability hook:

```json
{
  "hooks": {
    "Notification": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "projmux ai ingest claude-hook >/dev/null 2>&1 || true # projmux-managed:claude-hook:v1"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "projmux ai ingest claude-hook >/dev/null 2>&1 || true # projmux-managed:claude-hook:v1"
          }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "projmux ai ingest claude-hook >/dev/null 2>&1 || true # projmux-managed:claude-hook:v1"
          }
        ]
      }
    ],
    "PermissionRequest": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "projmux ai ingest claude-hook >/dev/null 2>&1 || true # projmux-managed:claude-hook:v1"
          }
        ]
      }
    ],
    "StopFailure": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "projmux ai ingest claude-hook >/dev/null 2>&1 || true # projmux-managed:claude-hook:v1"
          }
        ]
      }
    ],
    "SubagentStop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "projmux ai ingest claude-hook >/dev/null 2>&1 || true # projmux-managed:claude-hook:v1"
          }
        ]
      }
    ],
    "TeammateIdle": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "projmux ai ingest claude-hook >/dev/null 2>&1 || true # projmux-managed:claude-hook:v1"
          }
        ]
      }
    ]
  }
}
```

The marker lives inside the command string because JSON settings cannot carry
comments. Repeated installs are idempotent: projmux removes its marked commands
and re-adds the current managed command while preserving unrelated settings and
hooks. `--remove` deletes only marked commands, and `--dry-run` previews the
JSON without writing. Removal and unmanaged conflict detection scan every event
under the settings `hooks` object, not just the current catalog. If any event
already has an unmanaged command that invokes `projmux ai ingest claude-hook`,
projmux refuses to install over it because it cannot tell whether the command
is user-owned or stale projmux wiring.

The Claude hooks install list is catalog-driven. Projmux ships an embedded
default catalog at `internal/app/ai_hook_catalogs/claude.json`, and merges an
optional local override from:

```text
${XDG_CONFIG_HOME:-$HOME/.config}/projmux/ai-hooks.d/claude.json
```

Override events replace matching embedded events by name and append new names,
so a local file can disable a stale event with `"install": false` or add a new
Claude Code event before projmux itself is released. Supported `action` values
are `notify`, `state`, and `quiet`; install uses only events whose effective
`install` value is true. Removal and unmanaged conflict detection scan every
event under `~/.claude/settings.json` for the projmux command marker rather
than trusting the current catalog, so stale managed events from older catalogs
are still removed safely. `SubagentStop` is wired for hook-active pane marking
and ingest debugging only.

```json
{
  "provider": "claude",
  "events": [
    { "name": "Stop", "install": false, "action": "notify" },
    { "name": "FutureEvent", "install": true, "action": "quiet" }
  ]
}
```

Claude ingest has built-in notify/state handlers for the known completion,
notification, approval, prompt-submit, error, subagent-stop, and teammate-idle
events. Events without a specialized handler, including unknown future events,
are quiet/log-only after pane matching. Catalog `"action": "quiet"` is used for
that fallback; catalog `"notify"` and `"state"` entries still need built-in
handler code for event-specific queue rows or state transitions. Runtime action
overrides live in the same
`${XDG_CONFIG_HOME:-$HOME/.config}/projmux/ai-hook-actions.json` file used by
Codex and are managed from `Settings > Notifications > Hook quiet policy`.
They only affect ingest delivery; `projmux ai integrate claude` still uses the
catalog `install` field for installed hook events.

## Antigravity Hook Ingest

`projmux ai ingest antigravity-hook < payload.json` is available for manual
Antigravity CLI `agy` hook/statusline payloads. Projmux does not provide
`projmux ai integrate antigravity`, does not install Antigravity hooks, and
does not mutate Antigravity user config. The Delivery sources diagnostic
therefore reports Antigravity as a read-only unsupported/manual row. If users
wire it by hand, use an absolute `projmux` command path or a known cwd because
relative command paths failed the Phase 0b smoke.

The default known Antigravity catalog records only observed Phase 0b signals
and marks them `install: false`:

| Event/signal | Behavior |
| --- | --- |
| `PostInvocation` | marks the matched pane hook-active and writes a quiet ingest diagnostic; no notify queue entry is pushed |
| `Stop` | pushes a completion row, or a critical error row when `error` is present or `terminationReason` is non-normal |
| `Statusline` with `tool_confirmation_pending=true` | pushes a critical approval row; this is only active when a statusline/manual status payload is wired to ingest |
| `Statusline` without `tool_confirmation_pending=true` | marks the matched pane hook-active and writes a quiet ingest diagnostic |
| unknown events | mark the matched pane hook-active and write quiet ingest diagnostics only |

Antigravity notify rows use `agent=antigravity` metadata. Accepted fields
include `conversationId`/`conversation_id`, `cwd`, `workspace.path`,
`transcriptPath`, `terminationReason`, `error`, `fullyIdle`, `agent_state`,
`context_window`, and nested `statusline.tool_confirmation_pending`.
Antigravity ingest uses `conversationId` as pane thread metadata for matching
and as session-state resume metadata. Session restore uses
`agy --conversation <uuid>` only when that id is present and UUID-shaped;
otherwise preview and doctor render `resume unavailable`. Antigravity usage
quota HUD support remains unsupported because the stable usage signal is
`context-window-only` statusline data, not 5-hour/weekly quota data. Raw
payloads or transcript contents are not stored.

## Ingest Debug Log

Every `projmux ai ingest ...` path appends compact JSONL diagnostics to
`$XDG_STATE_HOME/projmux/ai-ingest.log`, or
`~/.local/state/projmux/ai-ingest.log` when `XDG_STATE_HOME` is unset. The log
records source, event, result, pane, match identifiers, and a short reason for
parse errors, unsupported events, missing pane matches, deduped bells,
state-only transitions, quiet high-volume events, and notify pushes. Raw hook
payloads are not stored.

Use `projmux ai ingest log` to inspect recent entries:

```text
projmux ai ingest log --tail 20
projmux ai ingest log --json --tail 20
projmux ai ingest log --path
```

The file is capped at 1 MiB. When an append grows it past the cap, projmux
keeps the most recent roughly 512 KiB and trims from the next JSONL boundary so
the remaining file still starts at a whole entry.

## Pre Create Abort

`pre-create` runs before `tmux new-session` on creation paths. A non-zero exit,
exec error, or timeout aborts the session creation. If a global `pre-create`
hook aborts, the project-local `pre-create` hook does not run.

This is the only Phase A hook event that can block creation.

## Post Attach

`post-attach` is supported only when projmux is already running inside tmux and
uses `switch-client` to move the current client to an existing session or
target. Outside tmux, `tmux attach-session` blocks until the user detaches, so
projmux does not run `post-attach` for outside-tmux attach paths in this phase.

## Environment

Hooks inherit projmux's environment, plus:

| Variable | Always set | Description |
| --- | --- | --- |
| `PROJMUX_SESSION` | yes | tmux session name |
| `PROJMUX_CWD` | yes | lifecycle working directory; for creation events this is the new session directory |
| `PROJMUX_SESSION_KIND` | yes | `persistent` or `ephemeral` for creation events; empty for `post-attach` |
| `PROJMUX_VERSION` | yes | projmux version string |
| `PROJMUX_SOCKET` | only if projmux used `tmux -L <socket>` | tmux socket name |
| `PROJMUX_PANE` | only for pane events | tmux pane id such as `%7` |

## Examples

### Global Post Create Stub

```bash
#!/usr/bin/env bash
echo "session=$PROJMUX_SESSION cwd=$PROJMUX_CWD kind=$PROJMUX_SESSION_KIND"
```

### Project Startup Command

```toml
[startup]
run = "git status --short"
```

### Per Session GH_TOKEN By Repo

```bash
#!/usr/bin/env bash
set -euo pipefail

case "$PROJMUX_CWD" in
  "$HOME"/source/repos/personal/*)  token=$GH_TOKEN_PERSONAL ;;
  "$HOME"/source/repos/work/*)      token=$GH_TOKEN_WORK ;;
  *) exit 0 ;;
esac

tmux set-environment -t "$PROJMUX_SESSION" GH_TOKEN "$token"
```

`set-environment` only seeds the session env that newly-spawned panes inherit;
it does not retroactively change the current shell. Open new panes via tmux
(`Ctrl-b c`, `Ctrl-b "`, etc.) to pick up the value.

## Troubleshooting

- **Nothing happens.** Check the execute bit on the global hook
  (`ls -l ~/.config/projmux/hooks/<event>`) or the project hook
  (`ls -l .projmux/<event> .projmux/hooks/<event>`). A missing bit makes
  projmux skip hook files silently by design. `.projmux/config.toml` does not
  need an execute bit.
- **`project hook ... requires trust; skipping in non-interactive context`** or
  **`project config ... requires trust; skipping in non-interactive context`.**
  Run the same projmux command from an interactive terminal to approve the file,
  or set `PROJMUX_PROJECT_HOOKS=off` if project-local execution should be
  disabled.
- **`projmux: <event> hook: ... timed out after 5s`.** Long-running work
  belongs in a backgrounded child (`(slow-thing &) >/dev/null 2>&1`). The hook
  itself must return within 5s or projmux kills it.
- **`projmux: <event> hook: hook ... exited with status N`.** The script
  returned non-zero. For `pre-create`, creation aborts; for other events,
  projmux logs once and moves on.
- **Lines appear with `[post-create] `, `[pre-create] `, or `[post-attach] `
  prefixes.** Expected; hook stdout/stderr are multiplexed into projmux's
  stderr stream.
