# jj-commitd — command reference Debounced commit daemon for Claude Code sessions. Per-session, file-scoped commits with ~3 s debouncing so rapid successive edits batch into one `jj commit` instead of racing. Optional. If the binary isn't on `PATH`, the hook script falls back to file-tracking-only mode that commits at session end. ## Install ```bash go install forgejo.zerova.net/sid/template-jj/cmd/jj-commitd@latest ``` See [`docs/building.md`](building.md) for building from source. ## Architecture ``` Claude Code hooks (bash) Unix socket Go daemon jj CLI ┌─────────────────────┐ ┌────────────────────┐ ┌──────────────┐ ┌────────┐ │ session-start │→ │ │→ │ │→ │ jj │ │ post-edit (edit N) │→ │ /tmp/jj-commitd- │ │ jj-commitd │→ │ commit │ │ post-edit (edit N+1)│→ │ .sock │ │ (this binary)│ │ │ │ session-end │→ │ │→ │ │→ │ jj ... │ └─────────────────────┘ └────────────────────┘ └──────────────┘ └────────┘ ``` - The bash hook client ([`scripts/jj-hook.sh`](../scripts/jj-hook.sh)) is installed into Claude Code's `.claude/settings.json` as an event hook. - On the first `session-start` the hook auto-spawns the daemon in the background. - Events travel over the Unix socket as one JSON object per line. - The daemon serialises all `jj` invocations through a single mutex so concurrent sessions never race. - When the last session ends and an idle timeout passes (30 min), the daemon exits and cleans up its socket. ## Usage The binary is normally *not* invoked directly — `scripts/jj-hook.sh` manages its lifecycle. For debugging / manual testing: ```bash # Start manually from a repo root (daemon blocks in foreground): REPO_ROOT=$(pwd) jj-commitd # Ask an existing daemon to log its status (writes to the daemon's log file): scripts/jj-hook.sh status # Gracefully shut down a running daemon: scripts/jj-hook.sh session-end # (or send a shutdown event directly — see below) ``` There is no `--help`, no `--version`, and no subcommand dispatch. The daemon reads all configuration from environment variables. ## Events The daemon consumes one JSON object per line over its Unix socket. All events are one-shot request/response; the daemon closes the connection after replying. | Event | Fields | Semantics | |-----------------|------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------| | `session-start` | `session_id`, `repo_root`, `pid` | Register a session. Response includes the inventory of other active sessions + their tracked files. | | `post-edit` | `session_id`, `file` (absolute path), `repo_root` | Track a file edit. Resets the session's debounce timer; the next idle window triggers `jj commit`. | | `session-end` | `session_id` | Flush any pending commit, record the session as ended. Daemon exits once all sessions end + idle out. | | `shutdown` | — | Immediate graceful shutdown. Used by cleanup scripts. | | `status` | — | Snapshot current session state to the log file. Returns `{"ok": true}`. | Unknown events are rejected with `{"ok": false, "error": "unknown event"}`. ### Example — manual protocol poke ```bash # Send a status request to an existing daemon: echo '{"event":"status"}' \ | nc -U /tmp/jj-commitd-$(echo -n $(pwd) | shasum -a 256 | head -c 16).sock ``` (The `repo_id` is a SHA-256 prefix of the absolute repo root — see `repoID()` in `cmd/jj-commitd/main.go`.) ## Configuration (environment variables) | Variable | Default | Max | Effect | |--------------------------|---------|-------|--------------------------------------------------------------------------| | `REPO_ROOT` | `$(pwd)` | — | Repo root the daemon manages. Auto-detected from `cwd` if unset. | | `JJ_HOOK_DEBOUNCE_SEC` | `3` | `30` | Debounce window in seconds. Edits within this window batch into one commit. | | `JJ_HOOK_STALE_MIN` | `30` | — | Minutes before a session with a dead PID is reaped. | | `JJ_HOOK_AUTO_SQUASH` | `0` | — | When `1`, `session-end` triggers the `squash-wip` flow (squashes the session's WIP commits into one). | | `JJ_AGENT_FEATURE` | — | — | **(Planned, not yet implemented.)** Human-readable feature name for commits and bookmarks. When set, commits become `wip(:)` and the session-end bookmark becomes `feat/` instead of `wip/claude-`. | Invalid or zero values for the numeric vars fall back to defaults. ## Paths All daemon state is per-repo, keyed by a SHA-256 prefix of `$REPO_ROOT`: - **Socket:** `/tmp/jj-commitd-.sock` - **Log:** `/tmp/jj-commitd-.log` (rotated when it exceeds 512 KB) Both clean up automatically when the daemon exits. Stale sockets from a crashed daemon are detected via a `DialTimeout` probe on startup and unlinked. ## Timing constants All baked in at `cmd/jj-commitd/main.go:46-53`; not user-configurable beyond the env vars above. | Constant | Value | Purpose | |----------------------|--------|----------------------------------------------------------------------| | `defaultDebounce` | 3 s | Default batching window for post-edit events. | | `quiescenceWindow` | 500 ms | Must see no new edits for this long before a commit fires. | | `maxDebounce` | 30 s | Cap on `JJ_HOOK_DEBOUNCE_SEC`. | | `idleShutdown` | 30 m | Time after the last session ends before the daemon self-exits. | | `defaultStaleSessionAge` | 30 m | Default stale-session reap age (overridable via `JJ_HOOK_STALE_MIN`). | | `maxLogSize` | 512 KB | Log rotation threshold. | ## Session lifecycle 1. **`session-start`** registers a new session with an empty `Files` map and `StartedAt = now`. The response carries an inventory of *other* active sessions — a Claude Code agent can inspect this to avoid stepping on another session's tracked files. 2. **`post-edit`** appends the edited file to the session's dedup set and resets the debounce timer. When the timer fires and the session has been idle for `quiescenceWindow`, the daemon: - Runs `jj diff --summary -- ` to confirm there are real changes (avoids empty commits from Claude re-reading an unchanged file). - Runs `jj commit -m "wip(claude:): " -- ` under a global `jjLock` so concurrent sessions can't race. 3. **`session-end`** flushes any pending commit and marks the session ended. If `JJ_HOOK_AUTO_SQUASH=1`, the hook then invokes the squash-wip flow. 4. When all sessions have ended AND no new traffic arrives for `idleShutdown` (30 minutes), the daemon exits cleanly. ## Session inventory consumption On `session-start`, the daemon responds with a JSON `sessions` array listing every *other* currently-active session and its tracked files: ```json { "ok": true, "sessions": [ {"id": "abc12345", "pid": 1234, "files": ["src/auth.go", "cmd/main.go"], "age": "2m34s"} ] } ``` **What agents should do with this:** 1. **Log it at startup.** Even if no overlap exists, the inventory tells you who else is active. This is useful context for the user. 2. **Check for file overlap.** Before editing a file, verify it doesn't appear in another session's `files` list. If it does, flag it to the user: *"Session abc12345 (pid 1234, active 2m34s) is also tracking `src/auth.go` — proceed anyway?"* 3. **Empty inventory is the common case.** Solo sessions (the most common scenario) return an empty array. Log nothing and proceed. 4. **Stale entries self-clean.** The daemon reaps sessions with dead PIDs and sessions older than `JJ_HOOK_STALE_MIN` (default 30 min). Don't rely on the inventory being perfectly current across a long session — re-read by sending `session-start` again if you need a fresh snapshot. A fresh session restart is always cleaner. The hook script (`scripts/jj-hook.sh`) logs the inventory to the daemon log on start but does not yet surface it to the agent automatically. In a future version this will be piped into the agent's startup context. ## Conflict detection When two sessions touch the same file, the daemon logs a warning (grep `/tmp/jj-commitd-.log` for `conflict`) and attributes the edit to whichever session's timer fires first. Per-session commit messages keep the history readable even when multiple agents interleave. ## Legacy cleanup On startup the daemon sweeps and removes any `/tmp/jj-claude-*-files` tracking files left over from pre-daemon hook versions. Safe to run alongside old installations — it won't disrupt an in-flight older hook since the files it cleans are ephemeral per-session markers. ## Fallback mode If `jj-commitd` is not on `PATH` when `scripts/jj-hook.sh session-start` runs, the hook falls back to file-tracking-only mode: - Every `post-edit` appends to `/tmp/jj-claude--files`. - On `session-end`, one big `jj commit` flushes all tracked files at once. - No debouncing, no per-session isolation beyond the file list itself. Functional, but loses the debounce and live-status benefits of the daemon. ## Troubleshooting - **"connection refused" from the hook** — stale socket. The daemon detects these on startup, but if you see it mid-session, kill any leftover `jj-commitd` processes (`pkill jj-commitd`) and retry; the next `session-start` will relaunch. - **Edits not committing** — check `/tmp/jj-commitd-.log`. Look for `commit error` or `jj exec` lines; the jj CLI's stderr is captured there. - **Disappearing edits across sessions** — grep the log for the file path; you're likely hitting a cross-session conflict. - **Daemon won't shut down** — `scripts/jj-hook.sh status` to inspect active sessions; send the `shutdown` event directly (see "Manual protocol poke" above) to force exit. ## See also - [`INTEGRATION.md`](../INTEGRATION.md) — installing hooks in a new or existing project. - [`scripts/jj-hook.sh`](../scripts/jj-hook.sh) — the bash client that talks to the daemon. - [`docs/planctl.md`](planctl.md) — the other binary in this repo. - [`docs/building.md`](building.md) — build from source.