The SubagentStart hook matches every agent type, but agents defined with an
explicit tool list (e.g. tools: Read, Grep, Glob, Bash) have no MCP tools, and
SubagentStart input carries no tool list to detect that. Append one sentence to
the SubagentStart context only (indexed and unindexed variants) pointing such
agents at `codebase-memory-mcp cli <tool> --help`. Longest variant is ~1.1 KB,
well inside the 2 KB context buffer.
Closes#2837
Signed-off-by: Shane McCarron <shane.mccarron@corvexconnect.com>
The search hook gives each Grep, Glob or Bash search the matching graph
symbols, but a session can keep searching text for questions the graph answers
in one call (#509). Once a session has run three searches with no
codebase-memory-mcp tool call, the next augmentation now adds one factual line
saying so. The next note comes after six searches, then twelve, and any graph
tool call resets the count.
- State: a per-session counter in the process that runs hook requests (the
daemon), keyed by the payload's session_id, in a bounded 64-slot table that
evicts the least recently used session, behind a spin lock. Only searches
that reach the graph lookup count. A search without an augmentation still
counts, and the note waits for the next augmentation.
- Reset: Claude's managed PostToolUse entry now matches
"Read|mcp__.*codebase-memory-mcp__.*", covering plain and plugin server
names. The previous Read-only entry, as installs up to v0.12 wrote it, is
recognized as owned, so install replaces it in place and uninstall removes
either.
- Scope: Claude Code only. Codex sends the same event payload with a turn_id
but has no reset hook, so its sessions are not tracked, and payloads without
a session_id are not tracked either.
- Cost: a graph tool call now runs one hook round-trip to the daemon for the
reset, about 130 ms on a local build.
Refs #509
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The PostToolUse change rendered the manifest twice per install and uninstall
(current and previous generation) and freed both with free(), which grew the
raw allocator count in src/cli/cli.c past its baseline (lint-memory-core).
The manifest is now written with cbm_json_writer_allocator() and released with
cbm_free(CBM_MEM_CLASS_OTHER, ...), including the frees that predate the
change, so cli.c drops from 170 to 166 raw sites and its baseline is lowered
to match. The rendered bytes are unchanged, so byte-exact ownership of
manifests written by earlier installs still holds.
Refs #509
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Copilot CLI's preToolUse output can only decide permissions, and its command
hooks fail closed on errors, so the graph context that Claude Code and Codex
searches get could not ride a pre-tool hook there. postToolUse
additionalContext is appended to the tool result the model sees on the same
turn (github/docs content/copilot/reference/hooks-reference.md). VS Code's
agent mode shares the Copilot harness and reads the same ~/.copilot/hooks
manifest.
- Manifest: $COPILOT_HOME/hooks/codebase-memory-mcp.json adds a PascalCase
PostToolUse entry, matcher "Bash|Grep|Glob|bash|powershell|grep|rg|glob",
running hook-augment --event PostToolUse --dialect copilot. The previous
lifecycle-only manifest, as installs up to v0.12 wrote it, stays recognized
as owned: install upgrades it in place and uninstall removes either
generation. A foreign manifest still fails closed.
- hook-augment: the Copilot dialect reads either payload form
(tool_name/tool_input or toolName/toolArgs), maps runtime tool names to the
Claude names (bash/powershell to Bash, grep/rg to Grep, glob to Glob),
parses arguments passed as a JSON string, and answers with top-level
additionalContext.
- Fast path: Copilot's forced PostToolUse goes through the no-op gate before
executable-identity hashing. The gate also skips tools the augmenter does
not read, because VS Code's local hook parser ignores matchers, so a
non-search shell call or an edit exits in about 20 ms instead of paying the
full round-trip of about 130 ms.
- The context header no longer says the search results are "below": after a
post-tool hook they are above it.
- Tests: the three Copilot foreign-manifest tests now create
.copilot/mcp-config.json. Without it Copilot CLI was never detected, so
install and uninstall never reached the manifest, and the tests also passed
with the ownership checks removed.
Refs #509
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Codex runs its hooks with the same contract as Claude Code: its shell tool is
named Bash, tool_input.command is a string, and PreToolUse hookSpecificOutput
additionalContext reaches the model (openai/codex hooks/src/events/
pre_tool_use.rs, core/src/hook_runtime.rs). Codex only had the SessionStart and
SubagentStart reminders (#330), so its grep and rg calls never got the graph
context Claude Code's searches get (#715).
- config.toml: the managed block adds [[hooks.PreToolUse]] matcher = "Bash"
running the same hook-augment command. The previous block (lifecycle pair
only, as installs up to v0.12 wrote it) stays recognized as owned, so an
upgrade replaces it in place and uninstall removes either generation.
- Inline forms (#1432): Codex's inline rewrite of our PreToolUse is replaced.
A user's own inline hooks.PreToolUse is kept and our block then leaves
PreToolUse out, because TOML cannot hold an inline array and array-of-tables
entries for the same key; install says so instead of failing.
- hooks.json: the Codex-only PreToolUse Bash entry is added and removed next to
the lifecycle pair, touching only the entry whose command is ours.
Codex asks users to re-trust changed hook definitions (/hooks).
Refs #715, #509
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The Windows regression test for #2145 created its profile directory
with test_mkdirp and read the result as a boolean. test_mkdirp returns
0 on success, so the test failed as soon as the directory existed and
never reached the detection it was written to check.
Inverting the check is not enough on its own. test_mkdirp uses
cbm_mkdir, which is the narrow _mkdir on Windows. Under an ANSI code
page such as 1252 it does not create the intended name: it creates a
directory whose name is the UTF-8 bytes read as code-page characters.
The old narrow stat finds that mangled directory and the new wide
lookup correctly does not, so the test would pass without the fix and
fail with it. th_mkdir_p goes through the wide API and creates the
real name, the way a non-ASCII Windows profile actually looks.
Since 2b329fe7, Claude Code is detected from settings.json,
.claude.json or the claude CLI, through cbm_file_exists, which is
already wide on Windows; a bare .claude directory no longer marks it
as detected. The test now asserts Cursor, which is detected by its
directory alone, so the assertion goes through dir_exists, the
function this branch changes.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Brings #2244 (agent detection through the UTF-8 path wrapper) up to
date with main a2227157. The branch was based on 45c45be5, 1,323
commits behind.
The merge is clean. The merged tree adds exactly this branch's 22
lines to main and removes 6, in src/cli/cli.c and tests/test_cli.c.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The two regressions sat in tests/repro/repro_issue221.c, which builds only
into the non-gating bug-repro runner, so CI could not stop the fix from
regressing. The Windows-only case also skipped on macOS and Linux, and
scripts/repro.sh reports any skipped case as an incomplete board.
Move both into tests/test_cli.c next to the other cbm_find_cli tests,
unchanged apart from their names: the cli suite runs on every CI leg, and
SKIP_PLATFORM is how that suite already marks Windows-only cases.
tests/repro/repro_issue221.c is back to its content on main.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
When an agent greps for a symbol that the graph knows, the PreToolUse
augmentation (Grep, Glob and Bash searches) lists the matching symbols. It now
closes with one line that is cbm's own statement, outside the untrusted symbol
block, naming the graph tools for the usual next question:
[codebase-memory] For these symbols, trace_path lists callers and callees,
get_code_snippet returns the exact source, and search_graph finds related
symbols by name.
The line carries no repository-derived value and is stated as facts, as Claude
Code asks of hook context. It is appended at the string's real end into
reserved room: the list loop's offset can run past its cap when a row is
truncated, so appending at that offset could have written past the buffer.
Searches without a hit, errors and non-search events are unchanged.
Refs #509
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Claude Code defers every MCP tool behind ToolSearch by default: at session
start the model sees only tool names and server instructions, while Grep and
Read are always loaded. Codex does the same and shows the server instructions
as the namespace description of the deferred tools. Agents therefore reached
for grep before the graph tools were even loaded (#509, discussion #2025).
- tools/list: search_graph, trace_path and get_code_snippet carry
_meta["anthropic/alwaysLoad"] = true (a documented per-tool hint), so the
structural discovery path loads upfront while the other 14 tools stay
deferred. About 4.6K characters of schema; clients that do not know the key
ignore it, as MCP reserves _meta.
- Server instructions (default profile) now state, keywords first and as
facts, which structural questions the graph answers and where grep fits
better: 309 -> 999 characters, within Claude Code's 4,096-character cut.
- SessionStart/SubagentStart: the untrusted-metadata label now covers the
graph-derived project name only. Wrapping the routing sentences too told the
model to treat cbm's own guidance as data. The other sentences are unchanged;
sanitization is unchanged.
The initialize byte budget is raised deliberately from 512 B to 1.5 KiB (1,188
bytes now); tools/list stays within its 21 KiB budget (20,938 bytes).
Refs #509
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
`cli list_projects` with no arguments blocked forever whenever fd 0 was a
pipe nobody closes: an ssh session's stdin, or a Node child_process.spawn
parent that never calls stdin.end(). That is the #1359 deadlock again.
The #1359 gate only skipped the stdin read for a tool whose schema
declares NO properties; #1181 then gave list_projects optional paging
properties, and the read came back for exactly the callers #1359 fixed.
It showed up as a 25-minute stall in the Windows smoke's Phase 1b over
ssh, and it is the same in v0.11.0.
The gate now asks what stdin is (terminal, regular file, or stream) and
what the tool needs:
- terminal: never read (unchanged);
- no properties: never read (unchanged);
- optional properties only: a regular file (`cli list_projects < args.json`)
is read, since a file always reaches EOF; a stream is not, and the call
runs with `{}` exactly as an interactive one does;
- any required property: file or stream, the documented
`echo '<json>' | cli <tool>` channel, unchanged; the bare call cannot
succeed without it.
Only list_projects is optional-only today; the other 16 tools all require
an argument. Piping JSON into list_projects is no longer read; flags,
--args-file and `< file` still are.
Proof (macOS, the release-tree binary vs this one, isolated runtime dirs):
- held-open pipe: `list_projects` hung until killed at 20 s -> returns in 0.4 s;
- `list_projects < args.json` ({"format":"json"}): JSON on both;
- `printf '{"project":"nope"}' | cli index_status`: same "project not
found" on both.
Tests: the #1359 schema seam now takes the stdin kind; new
cli_optional_argument_tool_never_reads_stream_stdin (fails before: the
old gate admitted a stream for list_projects) and
cli_stdin_kind_classifies_file_and_pipe; the whole-table sweep checks
file and stream separately and asserts it really covers an optional-only
tool.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Normalize Windows cache paths when reporting runtime settings and resolve
Git executables before spawning history queries. Keep executable lookup
results per thread and cover the ownership contract deterministically.
Support Git 2.43 while denying history-query network transports, and close
the activity database on daemon startup failure so Windows can clean up.
Validation: 463 native macOS tests, 421 Windows tests, 44 MSan UI tests,
and focused canonical lint passed. Production-only revert checks confirm
the repaired failures.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Follow-up to #2590: three more waits that a timeout decided.
cli_activation_quiesce_does_not_wait_on_bootstrap_startup:
- Its participant child joined the version cohort with a 45 s deadline. It
now uses UINT64_MAX, which waits for locks and answers a conflicting
holder at once.
- The child watched for the installer's maintenance request for at most
120 s. It now watches until a definite event: the request, an UNSAFE/IO
answer, or the parent closing a new install-done pipe once its install
has returned. A failed install therefore ends the watch at once instead
of hanging, and a slow install is waited for instead of failing.
cli_install_into_host_namespace_still_drains_host_cohort:
- The "drained host stopped serving" probe was one status request with a
2 s budget, so a slow host that still served could pass as drained (a
false green). It now asks through the same mute-retry rule as the
positive probe: a drained host is unreachable or reports stopping at
once, and a slow host that still serves is asked again until it answers.
cli_scope_host_serving_within and CLI_SCOPE_HOST_DRAINED_PROBE_MS are
gone.
Checked by hand: with the install made to fail before it requests
maintenance (an unknown option), the test ends in 3 s through the
install-done pipe. Without the pipe and the old deadline it would hang;
with the deadline it took 120 s.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Follow-up to #2589.
No timeout decides a cli_scope test any more:
- The status probe and the parent's client connect repeat an attempt that
reaches a live but mute endpoint (a loaded runner answering slowly). A
reply or a definite refusal decides. Each attempt keeps the API's finite
5 s budget. This replaces the 15 s status budget and the single 5 s
connect.
- The host child is reaped without a deadline (it used to get SIGKILL after
60 s).
- The host child joins its version cohort with UINT64_MAX, which waits for
locks but answers a conflicting holder at once (it used to have a 45 s
deadline).
A wedged child is left to the harness's per-suite ceiling.
cli_scope_fixture_finish restored HOME, CBM_CACHE_DIR and SHELL even when
start had failed before saving them, which unset them for every later test
in the suite. It now restores only what start saved.
New tests:
- cli_scope_fixture_finish_keeps_env_after_an_early_failure fails on main
at ASSERT(home_kept);
- cli_scope_retries_a_mute_endpoint_until_it_answers checks the retry rule
on scripted attempt outcomes.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
cli_install_skip_binary_unchanged_in_host_namespace_quiesces_nothing
failed once on 2026-10-09 at ASSERT(ready), with nothing else in the log.
cli_scope_fixture_start returned false silently from any of its setup
steps, and a 90 s deadline decided the wait for the host child's
readiness.
The fixture now records and prints the step that failed: the fixture
directory, the host directories, the runner's own image fingerprint, the
pipes, the fork, the host's start, or the client connect, with the OS error
where one applies. The host child prints which of its own steps failed
before it answers 'E'.
The readiness wait has no deadline any more, because no timeout may decide
a test. The child answers 'R' or 'E', or the pipe reaches EOF when it dies.
A child that wedges is left to the harness's per-suite ceiling.
New test: cli_scope_fixture_names_its_failing_setup_step.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
On macOS the signed-candidate preparation folded every step into one
boolean, so each failure printed the same line: "signed update candidate
preparation failed". A full /tmp read exactly like a broken codesign. That
is what a cli test hit on 2026-10-09, while a multi-GB index was filling a
nearly full disk.
Each step now records why it failed, and the update prints, for example:
error: signed update candidate preparation failed while writing the
unsigned candidate: No space left on device
The staging functions in activation_transaction.c keep the errno of the
failing write (or sync, close, read) through their cleanup, so a caller can
name it. The non-macOS path adds the OS error to its existing "failed to
stage verified update" line (POSIX only; Windows reports through
GetLastError). A failed fingerprint of the staged file now gets its own
message instead of "...: activation transaction completed".
A test seam makes the next staging write fail with a chosen errno. The new
test cli_update_names_the_failing_staging_step_and_os_error fails without
the cli.c change and passes with it, and checks that the old executable
stays in place. Also checked on real input: with /tmp on a small volume
that fills between two updates, the update prints the line above.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Every process start fingerprints its own executable for the daemon build
identity, and the hook path pays for that before it can answer. With the
portable transform that was ~1.2 s of CPU for the ~300 MB binary, so on a
slower machine hook-augment hit its 2000 ms deadline and printed nothing
(#2441).
Two changes in src/foundation/sha256.c:
- cbm_sha256_update compresses whole blocks straight from the input instead
of copying every byte through the context buffer.
- Block compression runs on the CPU's SHA-256 instructions when it has them:
ARMv8 SHA2 (sysctl on macOS, HWCAP on Linux, IsProcessorFeaturePresent on
Windows) or x86 SHA-NI (CPUID). The backend is probed once; the portable
transform stays the fallback.
Measured on an Apple arm64 Mac (macOS 26.6), release builds of main 72a2c0bc
and of this change, hook-augment SessionStart in fresh isolated dirs, 9
interleaved runs, medians:
- hook-augment: wall 1.448 s -> 0.376 s, user CPU 1.162 s -> 0.097 s
- SHA-256 of the 303 MB binary: 1.18 s -> 0.095 s CPU per pass (the
block-direct update alone, portable rounds: 0.74 s)
- with CBM_HOOK_DEADLINE_MS=1000 (a slower machine): before, exit 0 with
empty stdout after 1.01 s; after, the full 188-byte answer in 0.37 s
Digests are unchanged: the build id the new binary logs equals the file's
SHA-256, and the hook output is byte-identical. A new test compares the
hardware and portable backends at every length 0..299 and at block-boundary
sizes up to 1 MiB under eight update chunkings, plus the NIST million-'a'
vector; it fails if the ARM schedule is broken or if Apple arm64 falls back
to portable. The x86 path was checked under qemu-x86_64 -cpu max (2,436
comparisons equal, million-'a' correct) and falls back to portable on a CPU
without SHA-NI. On a Windows arm64 VM the probe picks the hardware path:
0.094 s against 0.82 s portable for the 303 MB binary, digest equal to
sha256sum's.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Require --delete-indexes for automatic or noninteractive index removal.
Keep the separate terminal prompt with a default of retaining indexes,
and preserve dry-run and guarded activation ordering.
Refs #2200
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
An agent in a checkout whose directory name differs from the repository
name (git clone .../repo_name.git some_name) could not find the indexed
project. The installed skill only said "list_projects - check if project
is indexed", so Codex and Copilot guessed the project from the repo name,
got "project not found" and stopped using the graph. Passing the working
directory did not help whenever it was not exactly the indexed root: a
path was only ever turned into the path-DERIVED name, which misses a
checkout indexed under an explicit name (the stored identity, see #1660)
and every directory below an indexed root.
Fix:
- mcp: a path-shaped project keeps the derived name when an index exists
under it (unchanged fast path); otherwise it resolves to the indexed
project whose stored root_path owns the directory. The deepest owning
root wins; two projects owning that root stay unresolved, so the
not-found error lists every project instead of guessing. Reuses the
list_projects identity reader and the canonical-path containment helpers.
- mcp: the project-not-found hint says how to pick the project (the name
whose root_path contains the working directory, or that absolute
directory; never a repo or folder name), so an agent that guessed can
recover.
- cli: the SKILL.md every skill client installs (Codex, Copilot and the
rest), the shared agent instructions and the Aider CLI instructions say
the same thing.
Proof (dev binary, synthetic HOME; repo_name cloned as some_name and
indexed by path; a second checkout other_dir indexed with name=my-alias):
before: project=<other_dir> -> project not found
project=<some_name>/pkg -> project not found
after: project=<other_dir> -> my-alias (index_status agrees)
project=<some_name>/pkg -> the some_name project
unchanged: the checkout root path and the explicit name resolve; the
unindexed sibling some_name_x stays not found; a guessed "repo_name"
stays not found and now carries the how-to-pick hint.
Tests:
- mcp tool_project_path_resolves_owning_project_issue1690: an explicitly
named root and its subdirectory resolve; normal naming and similar
names (some_name2, some_name_x) are not confused; two owners of one
root are never guessed; a guessed repo name gets the hint. RED 3x
before, green after, RED again when the production change is reverted
(the hint check also goes RED on its own revert).
- cli cli_guidance_resolves_project_by_root_path_issue1690: pins the
instruction in the skill, agent-instructions and Aider variants.
Fixes#1690
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A project indexed from a directory that is not a git repository was never
refreshed after its first index: init_baseline classified it is_git=false
and poll_project/check_changes returned early for it forever, so the graph
silently went stale. The comment in init_baseline even claimed such folders
were "polled as a plain directory", which was never true.
Add an opt-in config key, watch_non_git (default false, read once at daemon
startup like watcher_enabled). When on, a non-git root is polled on the
existing adaptive cadence (cbm_watcher_poll_interval_ms) by a tree
signature: the indexer's own cbm_discover walk (same skip lists, .gitignore
stack and .cbmignore), sorted by path and folded over (relative path, size,
mtime). A changed signature takes the existing reindex path with the same
commit-after-success discipline as the #937 dirty signature. The baseline
leaves the signature "unknown", so the first poll reindexes once
(at-least-once, covering edits made while the daemon was down).
Runaway guard: only files discovery would index enter the signature, so
cbm's own .codebase-memory output (built-in skip) and the cache directory
(pruned by absolute path) can never make a finished reindex look like a
new change; a scratch folder under an unrelated dirty repository is polled
on its own files and never inherits the ancestor's dirty state.
cbm_file_info_t gains mtime_ns, filled from the stat discovery already
performs, so the signature needs no second stat per file and stays
UTF-8-correct on Windows (discovery's wide stat).
Docs: README and docs/CONFIGURATION.md now say non-git roots are not
watched by default and document watch_non_git. The staleness signal in
index_status is left to #1561's freshness object. Refs #1948.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Codex rewrites its [mcp_servers] tables whenever it edits MCP servers
itself (`codex mcp add`, its MCP settings, wrappers such as Orca that
drive it) and drops comment decor on the way. From our managed block it
keeps the table but loses the begin marker, omits `args = []`, and leaves
the end marker as an orphan below whichever table follows. Reproduced
with codex-cli 0.153.4: after `codex mcp add orca -- /bin/echo hi` the
file holds an unmarked
[mcp_servers.codebase-memory-mcp]
command = ".../codebase-memory-mcp"
env_vars = ["CBM_CACHE_DIR", "CBM_RUNTIME_DIR"]
and a stray `# <<< codebase-memory-mcp MCP <<<` under [mcp_servers.orca].
Every later install then failed with op=mcp_install on a table cbm itself
had written, twice over: cbm_toml_remove_legacy_table refused the orphan
marker as malformed, and its owned-shape check predated env_vars (#1562,
#1664), so even without the orphan the table counted as foreign.
cbm_toml_remove_legacy_table now drops a lone orphaned marker line in the
same atomic write (we are the only author of those markers, #1558; only
that line is removed, nothing is guessed) and treats env_vars naming only
CBM_CACHE_DIR / CBM_RUNTIME_DIR as part of the owned shape. A table with
any other key or forwarded name is still foreign and left byte-identical,
and a foreign table keeps the file untouched, orphan included. The scan is
split into toml_strip_legacy_table + toml_legacy_classify_assignment so
the entry point reads the file, heals, strips and writes once.
The same helper backs the Grok Build install, which gains the same
self-heal.
Tests: cli_upsert_codex_mcp_adopts_codex_rewritten_table uses the exact
bytes Codex produced; config_toml_legacy_remove_adopts_owned_env_vars_only
pins the env_vars boundary (owned names adopted, any other name foreign).
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Why: the activation rendezvous is per OS account, so an install or update
run under another HOME / CBM_CACHE_DIR (a sandbox, a second profile) meets
the account's live daemon at the same endpoint. The daemon's
activation-shutdown handler verifies only the requester's build, never its
cache, so the CLI's scope decision is the only thing that tells "my daemon"
from "someone else's". That decision drained the cohort unless the active
cache was positively DIFFERENT: a busy session group (another activation or
a session start held it, so the active identity was never read) and an
unreadable active cache both fell through to "ours", printed "Stopping
active CBM sessions" and shut the daemon and every session behind it down.
Fix (user decision 2026-09-28, "unknown = foreign, keep it"): only a
positive match may drain. cli_activation_resolve_scope returns
CLI_ACTIVATION_SCOPE_ACTIVE only for an OK admission (exact identity, cache
included, or an empty group) or for a conflict whose cache fingerprint
equals this activation's. BUSY and a blank active cache now return the new
CLI_ACTIVATION_SCOPE_UNCONFIRMED, handled like a foreign cohort: no lock is
taken, no drain request is sent, the mutation proceeds, and the audit log
records "quiesce_skipped: active cohort ownership unconfirmed (...)". A
read-only status probe names the daemon that was left (pid, version) and
the notice says how to stop it: close its sessions, then run
`codebase-memory-mcp daemon stop`. The confirmed paths are unchanged: a
normal self-upgrade still drains its own daemon, a clearly foreign cache is
still left alone.
Proof (product binaries; private CBM_RUNTIME_DIR; an own permanent test
daemon started from the same build under a host HOME/cache; a sandbox
HOME/cache on the same runtime dir; the cohort maintenance gate held by a
second process, i.e. a busy session group):
- origin/main: "Stopping active CBM sessions and operations for
install...", the test daemon was stopped (daemon status: not running),
and the install still failed (rc=1 after 23 s, "could not be stopped
safely").
- this change: "Leaving the running CBM daemon untouched (pid 76713,
version dev): this install could not confirm that it serves the cache
this install targets (the session group was busy, so its cache identity
could not be read)." The test daemon kept running with the same pid and
the install completed (rc=0, 10 s).
A lifetime record whose cache field is zeroed is refused (UNSAFE) by both
builds with the daemon kept; that path never drained and is unchanged.
Tests (cli suite; fixture daemon in a private runtime dir; sandbox HOME):
- cli_install_foreign_home_unreadable_cohort_cache_keeps_host_daemon
(the new seam cbm_cli_set_activation_scope_cache_unreadable_for_test
blanks the observed active cache fingerprint)
- cli_install_foreign_home_busy_cohort_keeps_host_daemon (the maintenance
gate is held through the product's own lock-directory handle)
Both were RED 3x on origin/main for the right reason (the host daemon was
drained: ASSERT(host_serving)), are green with the fix, and turn RED again
when the fix is reverted. Controls stay green:
cli_install_into_host_namespace_still_drains_host_cohort (confirmed same
cache: drained, as before) and
cli_install_{skip_binary,binary}_into_foreign_home_never_drains_host_cohort
(clearly different cache: kept, as before).
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Why: arming the hook-augment deadline opens its breadcrumb log with
O_CREAT. The path came from raw getenv("HOME") as
"%s/.cache/codebase-memory-mcp/logs" in 1 KiB buffers with no length
check. A HOME near 1 KiB therefore created a file at the cut-off name
(".../logs/hook-augme") on every hook call, and a longer one created a
cut-off directory. The hook also ignored CBM_CACHE_DIR, which every
other cbm component honours for its cache and logs. The index skip log
had the same defect in its CBM_INDEX_LOG override: an override longer
than 1 KiB was copied in unchecked and opened "wb", which created or
truncated the file at its cut-off prefix and reported that name as the
run's logfile.
Fix: the breadcrumb log now resolves through cbm_resolve_cache_dir()
(CBM_CACHE_DIR, else HOME, else USERPROFILE, plus the default) to
<cache_dir>/logs/hook-augment-timeouts.log. Every snprintf into the
1 KiB buffers is checked, including the one for the CBM_HOOK_TIMEOUT_LOG
override. A path that does not fit means no breadcrumb, and the logs
directory is created only for a path that fits. The hook still exits 0
(it never fails the agent's hook call) and allocates nothing new. The
CBM_INDEX_LOG override gets the same check: an override that does not
fit writes no logfile and warns index.logfile_path_too_long, and the
index still completes. With a normal HOME the log stays where it was.
Proof (dev binary, synthetic roots, isolated CBM_RUNTIME_DIR, no-op
Bash payload on stdin; "created" = find over the synthetic root minus
the pre-made HOME/cache chains):
- 980-byte HOME. Before: exit 0, plus three directories and a file at
the cut-off name <HOME>/.cache/codebase-memory-mcp/logs/hook-augme
(1023 bytes). After: exit 0, nothing created.
- CBM_CACHE_DIR=<root>/cache. Before: the log landed under HOME.
After: <root>/cache/logs/hook-augment-timeouts.log.
- 1000-byte CBM_CACHE_DIR. After: exit 0, nothing created.
- Normal HOME: <HOME>/.cache/codebase-memory-mcp/logs/
hook-augment-timeouts.log before and after.
- 1096-byte CBM_INDEX_LOG via `cli index_repository`. Before: a file
at the 1023-byte cut-off name, reported as the logfile. After: status
indexed, skipped_count 1, no logfile, nothing written.
Tests: RED 3x on the unfixed code for the reasons above, green with
the fix, RED again with the fix reverted:
- cli: cli_hook_augment_log_long_home_creates_no_cut_path,
cli_hook_augment_log_honours_cache_dir, plus the positive control
cli_hook_augment_log_default_location_unchanged (green before and
after).
- index_resilience: index_long_log_override_writes_no_cut_path.
The fixtures sit under link-resolved temp roots: the kernel bounds the
expanded path, so under macOS's /tmp -> /private/tmp link a near-1 KiB
path can be neither created nor probed, and the bug stays hidden.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Three detection defects kept `codebase-memory-mcp install` from
configuring agents people run, and made it configure one they never
installed.
OpenCode without a config file or a CLI on PATH (#1167, #1180).
OpenCode was detected only from its global config file or directory,
OPENCODE_CONFIG_DIR, or an `opencode` found on PATH. It now also counts
as installed when its data directory exists: ~/.local/share/opencode on
every OS, %USERPROFILE%\.local\share\opencode on Windows
(opencode.ai/docs/troubleshooting, "Storage"; the same page names it as
the global config location of older installs). The OpenCode CLI and the
Desktop app both create it -- the Desktop's bundled opencode-cli server
uses the same XDG paths (anomalyco/opencode packages/core/src/global.ts
creates the config and data dirs at startup;
packages/desktop/src/main/sidecar.ts only redirects XDG_STATE_HOME) --
so detection no longer depends on PATH. The install target
~/.config/opencode/opencode.json(c) -- %USERPROFILE%\.config\opencode
on Windows per the same docs page -- is unchanged and right for CLI and
Desktop alike. A PATH longer than 4 KB, which hides every CLI from
find_in_path(), is handled separately by PR #2381.
Hermes home on Windows (#1180). Native Windows Hermes -- the install.ps1
CLI, the MSIX package and the Desktop app -- defaults HERMES_HOME to
%LOCALAPPDATA%\hermes and falls back to a legacy ~/.hermes only while
that directory is absent (NousResearch/hermes-agent
website/docs/user-guide/windows-native.md, "Data layout";
apps/desktop/electron/data-paths.mjs, resolveDesktopHermesHome). cbm
probed and wrote ~/.hermes only, so a Desktop install was never detected
and a Hermes found on PATH got its config.yaml and skill in a home it
never reads. On Windows the home now resolves to
<home>/AppData/Local/hermes unless only ~/.hermes exists, using the
home-derived AppData convention of the existing Zed, goose, Kilo and VS
Code probes; detection, install and uninstall all follow it. HERMES_HOME
still wins, and macOS/Linux keep ~/.hermes.
Claude Code from a bare ~/.claude (#1180). Any ~/.claude directory
counted as a Claude Code install, so a user who only had the folder got
hooks, skills and MCP entries for a client they never installed.
Detection now needs what Claude Code itself leaves behind: settings.json
in the config dir, .claude.json in the home (in CLAUDE_CONFIG_DIR when
set), or the claude CLI.
Proof on macOS against synthetic HOME/cache/runtime dirs with a minimal
PATH (`install --dry-run --skip-binary`, then a config-only install):
bare ~/.claude main: Claude-Code fix: none
~/.local/share/opencode only main: none fix: OpenCode
In the OpenCode case the fixed install writes the codebase-memory-mcp
entry to ~/.config/opencode/opencode.json; main writes nothing. Controls
(a launched Desktop layout, a real Claude user) are detected by both.
The Windows Hermes home is covered by a platform-parameterised resolver
test that runs on every host plus a _WIN32-bound end-to-end detection
and plan test.
Tests (cli suite, 335 passed): seven new tests; five fail on main three
times out of three (the _WIN32-bound Hermes test and the look-alike
control are green on macOS by design). Reverting the Claude+Hermes half
and the OpenCode data-dir probe separately turns exactly their own tests
red. Nine existing Claude fixtures that used a bare ~/.claude as the
install marker now write .claude.json; two Hermes tests and smoke-test
8x expect %LOCALAPPDATA%\hermes on Windows.
Refs #1167Fixes#1180
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
cbm_toml_upsert_managed_block and cbm_toml_remove_managed_block treated
everything between the "# >>> codebase-memory-mcp MCP >>>" markers as
ours: install replaced the whole span with the fresh block, uninstall
deleted it, and the conflict check skipped that same span. When our block
ended ~/.codex/config.toml, Codex Desktop appended its own tables below it
while the closing marker stayed the last line, so [mcp_servers.node_repl],
[desktop] and the [marketplaces.*] tables sat inside the markers. The
0.10.4 -> 0.11.0 upgrade deleted all of them, together with the
startup_timeout_sec the user had added to our own table.
The span is now read as TOML sections, each classified against the tables
the block declares:
- owned: a declared table (the k-th [[element]] of a declared array). The
keys the block writes are replaced; every other key, comment and blank
line in it stays in the table.
- attached: a sub-table of a declared table, such as
[mcp_servers.codebase-memory-mcp.env]. It is part of our server entry
and stays with it; outside the markers it would collide with the next
install.
- foreign: any other table, moved byte for byte and in order to just after
the closing marker, so from then on the markers bracket only our entry
and later appends land outside them.
- lead: lines above the span's first table, moved above the opening
marker, where they keep the table they belong to.
Uninstall of the Codex, Grok and Kimi blocks goes through the new
cbm_toml_remove_managed_block_owned with the header each block declares:
it removes our table with its keys and sub-tables and leaves everything
else in place. cbm_toml_remove_managed_block keeps the historical
whole-span contract.
Content whose ownership cannot be decided fails closed with the file
untouched: a second copy of a declared table, an undeclared extra
[[element]] of a declared array, a child of a declared array element, an
unparsable line in an owned table, a user key colliding with one the block
writes, a move that would hand the keys below the block to another table,
and a rewritten file whose tables conflict with the block.
Preserving was chosen over failing closed on every foreign table because
Codex keeps appending below a closing marker that ends the file. A
refusing installer would block every later install and uninstall until
the user hand-edits the TOML, and a hand edit is itself the likeliest way
to lose those tables. Moving whole sections keeps every byte and every
key's table, and the shapes that cannot be attributed still refuse.
Without foreign content the output is byte-identical to the old rewrite.
Fixes#2228
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The cache directory holds two internal SQLite stores next to the project
indexes: the user's settings (_config.db) and the cross-repo store
(_cross_repo.db). cbm_list_indexes, cbm_remove_indexes and
count_db_indexes matched every "*.db" there. As a result:
- install --reset-indexes and uninstall (when index deletion is confirmed)
deleted the user's config (every `config set` value silently reverted
to its default) and the cross-repo store along with the indexes;
- the "Found N index(es)" listing and count over-reported by up to two.
pass_cross_repo.c already excluded both stores by exact filename, but in
its own copy of the check. Centralise the rule in foundation/str_util:
cbm_is_internal_cache_db() (exact names, so a real project whose name
starts with "_" or contains "config" is still an index) and
cbm_is_project_index_db(). Every enumeration of the cache directory's
.db files (list, count, remove, cross-repo target scan) now calls it, and
cbm_config_open() builds its path from the same CBM_CONFIG_DB_FILENAME
constant.
Proof on a real isolated cache (one indexed Go repo, `config set
auto_index true`, a _cross_repo.db present):
before: "Found 3 index(es)", "Removed 3 index(es)", cache left empty,
`config get auto_index` -> false
after: "Found 1 index(es)", "Removed 1 index(es)", _config.db and
_cross_repo.db kept, `config get auto_index` -> true
Test: cli::cli_index_enumeration_skips_internal_cache_dbs (RED on main,
green with the fix, RED again on revert).
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>