Reference for the repo-local CodeLens launchd daemons — deploy, redeploy, and codesigning recovery. Extracted from CLAUDE.md to keep the per-turn context lean.

Dev daemon vs consumption daemon (blast-radius isolation)

Each runtime is a single writer. Reviewer/planner and builder/refactor sessions share one HTTP endpoint and select their profile/RBAC per session; a second process for the same project is rejected by the project-writer lease. The machine's consumption endpoint is :7838, while this repo keeps a separate working-tree endpoint on :7736 so rebuilding the dev binary does not drop other projects' sessions:

Role Label Port Binary Who uses it
Consumption dev.codelens.mcp-mutation 7838 codelens-mcp-http global Codex/Claude/Cursor host attach overrides
Dev dev.codelens.mcp-dev-mutation 7736 codelens-mcp-http-dev this repo's .mcp.json — rebuild freely

The two can only coexist if the consumption daemon does not use this repo as its default project: the default project's writer lease is held for the daemon's lifetime, so a consumption daemon started on the repo keeps the dev daemon in a project_writer_busy respawn loop. Install the consumption daemon with an empty default project (the working directory, telemetry and logs stay in the repo); unbound sessions then see an empty project instead of this repo:

bash scripts/install-http-daemons-launchd.sh . --default-project ~/.codelens/daemon-default --run-at-load --load
bash scripts/install-http-daemons-launchd.sh . --label-prefix dev.codelens.mcp-dev \
  --bin-path "$PWD/.codelens/bin/codelens-mcp-http-dev" --mutation-port 7736 \
  --semantic --run-at-load --load

Only the daemon whose default project is the repo writes the repo-local .codelens/config.json host attach URLs, so the consumption install no longer flips them away from :7736. After redeploying either daemon, /mcp reconnect all (Claude Code 2.1.284+) re-attaches every failed server at once.

The old *-readonly labels are compatibility leftovers only. The installer and redeploy script disable and boot them out before touching the canonical writer; they never generate or bootstrap a readonly plist.

Shared-daemon LSP execution

Reviewer/planner/read-only profiles share the same LSP pool as mutation profiles, so profile selection is not the subprocess authorization boundary. All profiles use the engine-wide registered-recipe policy documented in runtime-knobs.md: exact recipe arguments and a canonical executable captured from operator-controlled paths. A bound repository's node_modules/.bin is never trusted merely because the repository contains a same-named server shim.

Keep the launchd PATH and CODELENS_LSP_PATH_EXTRA values operator-owned. For mutually untrusted repositories, use OS/container isolation as well; a trusted language server may still execute language-specific project hooks even though callers cannot choose the process or its arguments.

codelens-mcp-plugin/.mcp.json points at :7736, so dogfooding the working-tree build never touches the shared consumption daemon. Iterate with:

bash scripts/redeploy-dev-daemon.sh          # rebuild + resign + restart :7736 only

First-time setup (creates one canonical plist) is documented at the top of scripts/redeploy-dev-daemon.sh. Use redeploy-daemons.sh for the deliberate consumption release path.

HTTP Daemon Operations (this repo)

The canonical repo-local launchd agent shares the on-disk index and uses the project-writer lease plus register_agent_work / claim_files for session coordination:

  • dev.codelens.mcp-mutation → :7838, default profile builder, mode mutation-enabled — canonical writer shared by all host sessions

The consumption clients (global ~/.claude.json, ~/.codex/config.toml and repo-local host attach overrides) all use http://127.0.0.1:7838/mcp. Session initialization selects readonly/review/builder (and RBAC principal) on that same endpoint. Restart cycle (preferred path):

bash scripts/redeploy-daemons.sh --probe          # quick: cp + xattr/codesign + kickstart + LISTEN + tools/list
bash scripts/redeploy-daemons.sh --build --probe  # also runs cargo build --release --features http,semantic
bash scripts/daemon-stale-check.sh                # read-only: compare daemon binary git sha to source HEAD (exit 1 if stale; exit 0 when no LaunchAgent is installed)

What the script does: cp target/release/codelens-mcp → .codelens/bin/codelens-mcp-http, xattr -dr com.apple.provenance ${target} (otherwise macOS gatekeeper SIGKILLs the daemon with OS_REASON_CODESIGNING), codesign (ad hoc by default, or with CODELENS_CODESIGN_IDENTITY, see below; the copy is signed as <target>.staging and swapped in only after signing succeeds), disable/bootout gui/$UID/dev.codelens.mcp-readonly, then launchctl bootout/bootstrap plus kickstart -k gui/$UID/dev.codelens.mcp-mutation, wait for LISTEN on :7838, and (with --probe) issue one tools/list request.

An ad-hoc signature is keyed to the binary's content hash, so macOS treats every rebuild as a new app. The first time the redeployed daemon opens a file in ~/Downloads, ~/Documents or ~/Desktop, macOS shows "‘codelens-mcp-http’ would like to access files in your Downloads folder", and that open() blocks until someone answers. Binds for projects there return binding_status: "building" until then (CODELENS_BIND_BUDGET_SECS); measured 2026-10-10: 234.8 s, then over ten minutes, of an index open waiting on the dialog.

Sign with a certificate instead, so one consent survives rebuilds:

  1. Keychain Access → Certificate Assistant → Create a Certificate…: name CodeLens Local, Identity Type Self-Signed Root, Certificate Type Code Signing. (Any code-signing identity works; list them with security find-identity -v -p codesigning.)
  2. Export it for the deploy shell, e.g. in ~/.zshrc: export CODELENS_CODESIGN_IDENTITY="CodeLens Local".
  3. Redeploy (bash scripts/redeploy-daemons.sh --build --probe, and scripts/redeploy-dev-daemon.sh). The first signing may ask for keychain access to the key (choose Always Allow), and the next protected-folder access asks for consent one last time.

The binary is signed with identifier dev.codelens.mcp-http (CODELENS_CODESIGN_IDENTIFIER overrides it). A named identity that is missing or fails to sign aborts the redeploy before the binary is replaced; it never falls back to ad hoc, which would bring the dialog back silently.

A keychain other than the login keychain locks again after a reboot (or on its own lock timeout). codesign then waits on a password dialog that a non-interactive shell cannot answer, so the signing step gives up after CODELENS_CODESIGN_TIMEOUT_SECS (default 60) and prints the keychain to unlock: run security unlock-keychain <that keychain> and redeploy. The dialog stays on screen after the timeout; cancel it.

Deprecation removal-gate telemetry (ADR-0018 D3)

The installer sets CODELENS_TELEMETRY_ENABLED=1 on the daemon plist by default (--telemetry 0 to opt out), so every dispatched call appends one JSONL line to <repo>/.codelens/telemetry/tool_usage.jsonl. This is the evidence stream for tool-removal gates: a deprecated tool may be removed after one release of telemetry shows no legitimate callers. To evaluate the coordination-quartet gate:

jq -r 'select(.tool == ("register_agent_work","list_active_agents","claim_files","release_files"))
       | [(.timestamp_ms/1000 | todate), .tool, .surface, .success] | @tsv' \
  .codelens/telemetry/tool_usage.jsonl.1 .codelens/telemetry/tool_usage.jsonl 2>/dev/null

No output over the observation window ⇒ the removal gate is satisfied.

Read both generations. The log rotates by size: once tool_usage.jsonl reaches CODELENS_TELEMETRY_MAX_BYTES (16 MiB by default) it is renamed to tool_usage.jsonl.1 and a fresh file starts. The rename replaces any older .1, so exactly two generations survive and disk is bounded at twice the ceiling. Querying only the current file can therefore show a window shorter than you think and report a false zero — always pass both paths, oldest first, as above.

Sizing: a daemon in daily use wrote about 193 KB/day when measured on 2026-09-05, putting one generation near 85 days and the pair well clear of the 30-day window ADR-0010 evaluates gates over. Raise CODELENS_TELEMETRY_MAX_BYTES if your traffic is heavy enough that two generations would not span the window.

Project-binding precedence and lifetime

HTTP sessions resolve competing project declarations in this order:

  1. an explicit prepare_harness_session(project=...) or activate_project(project=...) request
  2. initialize.params.project
  3. the recurring x-codelens-project request header
  4. the daemon's startup project

Higher-precedence bindings are not overwritten by a lower-precedence recurring header. Requests with the same source can still switch projects. Header updates and the request-local metadata snapshot are captured atomically, so concurrent requests for one session cannot execute against each other's workspace.

prepare_harness_session reports the resolved path as project.effective_project, its owner as project.binding_source, and the binding lifetime in project.persistence_semantics. Explicit tool and initialize bindings persist across calls for the live HTTP session and are restored from the session journal after an idle expiry or a daemon restart (see runtime-knobs.md). A repeated x-codelens-project header re-establishes a header-owned binding during resurrection; the response carries x-codelens-session-resurrected: 1 when that fallback occurs. A header path that does not resolve to a project root (the home directory, a missing path, or an unmarked folder when CODELENS_ALLOW_MARKERLESS_ROOT=0) is treated as no binding and the request uses the daemon's project; an explicit project= with such a path still fails.

Binding Claude Code sessions to their launch directory

Claude Code expands ${VAR} in MCP url and headers, so one user-scope entry can send each session's launch directory. Put it in the URL, not a header:

"codelens": {
  "type": "http",
  "url": "http://127.0.0.1:7838/mcp?project=${PWD}",
  "headers": { "x-codelens-client": "claude-code" }
}

The daemon reads ?project= exactly like x-codelens-project (the header wins when both are present). A header cannot carry a non-ASCII path: from a directory named in Korean, Claude Code 2.1.296 with x-codelens-project: ${PWD} sent no request at all, so the server silently disappeared from that session. In the URL the same value is percent-encoded (/mcp?project=/Users/.../%EA%B9%A1...) and arrives intact. Measured 2026-10-10 against a header-capturing stub: the value came first on server/discover (with MCP-Protocol-Version: 2026-07-28) and then, after the stub's 400, on initialize. A subdirectory resolves to the enclosing project root. Only %XX is decoded, so + stays +; a path containing & or # is cut short and falls back to the daemon's project.

Before adopting it:

  • Deploy a daemon that degrades unresolvable header paths (above). An older daemon fails every call of a session launched in $HOME.
  • Keep CODELENS_ALLOW_MARKERLESS_ROOT=0 in the daemon's environment. The installer writes it by default (--markerless-root deny); markerless directories are otherwise accepted, so a session launched in, say, ~/Downloads would index that whole folder. An existing plist needs the key added (or a reinstall) and a launchctl bootout/bootstrap, since kickstart does not reread the environment.

Manual fallback (if the script is unavailable):

cp -f target/release/codelens-mcp .codelens/bin/codelens-mcp-http
xattr -dr com.apple.provenance .codelens/bin/codelens-mcp-http
codesign --force --sign - .codelens/bin/codelens-mcp-http
launchctl kickstart -k "gui/$(id -u)/dev.codelens.mcp-mutation"
sleep 4 && pgrep -fl codelens-mcp

If pgrep shows nothing after restart, the binary is missing --features http (see the Feature Flag Matrix in ../../CLAUDE.md) — check .codelens/reports/launchd/dev.codelens.mcp-mutation.err.log. If the err log shows last exit reason = OS_REASON_CODESIGNING, the xattr/codesign step was skipped.

Drift signals (why the daemon asks to be restarted)

prepare_harness_session / get_capabilities can attach a restart_recommended warning. Two independent signals feed it (crates/codelens-mcp/src/build_info.rs):

reason_code Trigger Meaning
stale_daemon_binary on-disk executable mtime is newer than the daemon's start time the daemon outlived its own binary — a rebuild landed but the process was never restarted
head_git_sha_mismatch daemon's compile-time BUILD_GIT_SHA differs from the project's HEAD a commit merged after the binary was built is silently absent

Precedence: stale_daemon_binary wins the reason_code slot when both fire, so existing consumers keep their semantics.

Three refinements keep this signal from crying wolf:

  • Common-prefix SHA comparison (issue #221). git rev-parse --short widens its output on prefix collisions, so the two sides can describe the same commit at different widths. SHAs match when one is a prefix of the other, subject to a 4-character minimum.
  • "unknown" sentinel — emitted by build.rs for non-git builds — is treated as no signal, not as a mismatch.
  • Binary-relevant classification. Commits that touch only hooks, docs, scripts, or .github/ produce a byte-identical rebuild; warning on them prompts a restart that drops live MCP sessions for no gain. The daemon runs git diff --name-only <binary_sha>..<HEAD> -- crates Cargo.toml Cargo.lock — the same path set scripts/daemon-stale-check.sh uses for its "binary-equivalent" verdict — and suppresses head_git_sha_mismatch when that diff is empty. Fail-open: if git is unavailable or either SHA is unreachable the classification is unknown and the warning is kept. The mtime signal is never suppressed by this path.

The HEAD comparison only runs when the daemon executable and the project resolve to the same git root (should_compare_project_head). Because the repo-local daemons run from .codelens/bin/ inside this repo, the comparison is active here; a daemon installed outside the project tree simply reports no HEAD signal. Set CODELENS_HEAD_GIT_SHA_OVERRIDE to force the comparison on.

launchd exit 78: the spawn-failure wedge

Symptom — every service is dead at once and refuses to come back:

launchctl print "gui/$(id -u)/dev.codelens.mcp-mutation" | grep -E 'state|last exit'
# state = not running
# last exit code = 78

launchctl kickstart -k reports success and schedules a spawn, but no process ever appears (pgrep -fl codelens-mcp stays empty) while running the same binary by hand works fine. Once launchd has wedged this way, kickstart cannot clear it — only a full re-registration does:

for label in dev.codelens.mcp-mutation dev.codelens.mcp-dev-mutation; do
  launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
  launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/$label.plist"
done
sleep 4 && pgrep -fl codelens-mcp

Note that com.apple.provenance reappears on the binary after modern macOS touches it; its presence alone is not the blocker here. Reach for the xattr/codesign path only when the err log actually names OS_REASON_CODESIGNING (see above) — otherwise re-registration is the cure.