Docs carried an OpenCode custom-agent recipe with id "opencode"; without
absorption those customs get renamed to "opencode-2" at load while pins
and saved sessions silently retarget a fresh default preset.
Replace the hand-rolled regex extraction + basename index with
metadataCache.getFileCache().links. The cache excludes links inside code
blocks/inline code (fixing false positives like a C++ [[nodiscard]] in a
code sample), separates embeds and frontmatter, and hands back already-
tokenized links with positions — so the regex, #/| splitting, embed
exclusion, and the per-prompt vault scan all go away.
Each link now resolves to its single destination via Obsidian's own
getFirstLinkpathDest (the file it would actually open), so the
resolved/unresolved/ambiguous three-state collapses to resolved/
unresolved and the basename index (and its full-vault scan) is gone.
Aliases come from the raw 'original' text, not displayText, which
Obsidian auto-populates for section links. Selection scoping now filters
by cached link position instead of re-scanning the selected substring.
Sibling-block emission, ref correlation, empty-skip, slash-command
suppression, and the byte-identical note bodies are all unchanged.
25 unit tests over the two pure modules. Formatter: wrapper/ref escaping,
empty→'', resolved/unresolved/ambiguous rendering, displayText/section,
XML escaping, the 50-link cap (default + override), and WSL/relative path
handling. Resolver: basename indexing + collisions, embed/anchor
exclusion, alias/section parsing, composite-key dedup, and single/
unresolved/ambiguous resolution via an injected fake app. Adds a minimal
TFile to the obsidian test stub for instanceof checks.
registerAgentCommands duplicated the id/displayName mapping; ordering
and display-name resolution now live in one internal enumeration with
getAll/getAvailable views on top.
Disabling removes an agent from lists, menus, and commands; resolution
(pinned blocks, restored sessions) keeps working. Runtime enforcement
keeps the default agent enabled and repairs the all-disabled state.
Avoid exporting an empty ANTHROPIC_API_KEY etc. into the agent process
for account-based logins; also skip the env write when the secret
resolves empty (deleted from the Keychain).
Replace the four per-agent settings fields with a presetAgents record
driven by a static registry (services/preset-agents.ts). Normalization
moves to a pure function that reproduces the legacy data.json migrations
verbatim, preserves unknown presetIds, and takes the secret-storage side
effects as an injected callback. Enumeration, resolution, API-key
injection, and the settings UI all iterate the registry; covered by
migration and characterization tests.
The rename sync added a second full-file writer to the transcript, breaking
the single-writer-per-file assumption saveSessionMessages was designed under
(it predates sessionLock, which was introduced to protect the savedSessions
array). A rename racing a turn end could read the file, lose the CPU while the
turn-end save wrote newer messages, then write its stale copy back — silently
dropping the newest messages from disk, permanently if the conversation was
never continued.
Run saveSessionMessages inside sessionLock like every other session-persistence
writer, so the two full-file rewrites serialize. Callers are fire-and-forget,
so nothing user-facing blocks. As a bonus the metadata snapshot is now taken
after any queued rename and sees the fresh title.
The new test reproduces the interleaving deterministically by gating the
rename's write, and fails without the lock.
A transcript file only carried sessionId/agentId/messages, so a session
evicted from the capped data.json index lost its handle: no cwd to reopen it
with, no display title (renames especially), no owning embed block, no
creation time. The vault already holds such orphaned transcripts — readable
but hard to reuse.
Snapshot the index entry (cwd/title/embedId/createdAt/updatedAt) into the
file on every turn-end save, making the archive self-contained for future
search/restore. The index entry stays authoritative while it exists; the
snapshot is a copy taken at the last save. Details:
- carry-over: if the entry is already gone (evicted while the session stayed
open), the previous snapshot is read back from the existing file instead of
being dropped by the wholesale rewrite
- rename sync: updateSessionTitle also patches the file's title snapshot
(best-effort, silently skipped on error), covering the
rename-then-never-continue case that no turn-end save would refresh
- absent optional fields are omitted (never written as null), the file format
version stays 1, and pre-feature files without a snapshot still load
The test stub's adapter grows a Map-backed in-memory FS (read/write/mkdir) to
cover the snapshot, carry-over, rename-sync, and backward-compat paths.
The saved-session cap evicted by array position (pop the tail = oldest
inserted), while every reader orders by updatedAt — so once the list hit 50,
saving a new session could silently drop an entry that was in active use
(shown at the top of the history list), including an embedded persist block's
conversation mapping. In-place updates never reorder the array, so a
long-lived but recently-used entry sits at the tail indefinitely.
Evict by oldest updatedAt instead, matching the read/UI axis: the entries
that go are the ones actually least recently used. The cap is also enforced
on updateSessionTitle's createIfMissing insert, which previously slipped past
it. Eviction removes only the index entry — transcript files under sessions/
are intentionally kept as an archive.
updatedAt means "last activity" (types/session.ts) and backs the "last used"
ordering the #320 review already guards elsewhere, plus
getSavedSessionByEmbedId's newest-wins resolution. updateSessionTitle was the
one writer bumping it for a metadata edit, so renaming an old conversation
moved it to the top of the history list — and for an embedded persist block,
made the block silently restore that old conversation instead of its current
one. Renames now keep updatedAt; tests pin both behaviors.
The embed dedup replaced a persist block's saved session on every new
sessionId (and, since the previous commit, also deleted its transcript), so
switching agents or starting a new chat in the block destroyed the earlier
conversation — inconsistent with the plugin's model where sessions stay
recoverable from Session History.
Embedded persist sessions now carry an embedId tag but accumulate like any
other session (sessionId-only dedup); nothing is replaced or removed. Restore
still resolves the block's latest via getSavedSessionByEmbedId (newest embedId
match). Tests updated to cover accumulation.
- ensureEmbedId: require a live agent-client/agent fence and re-validate the
body via parseAgentBlock before injecting an id, so a stale section can't
splice an id into an unrelated fence
- restore embedded persist sessions by embedId (independent of agent/cwd) and
relaunch under the saved agent when it differs
- honor a right-pane quick-action button regardless of the chatViewLocation
default
- queue pre-mount quick-action prompts instead of overwriting
- subscribe the chat panel to agent settings (built-in + custom) so labels and
the agent list refresh on edit; clear staged attachments when a prompt is
injected
- reject present-but-invalid block fields instead of silently defaulting
- widen the embed id to 16 hex; use overflow-wrap in the embedded blocks
- cover the block parser and session storage with unit tests
parseAgentBlock is a cheap pure function and embedded blocks are not
re-processed on re-render, so the LRU parse cache rarely hit while adding
module-level mutable state and an immutability contract on its result.
Remove the cache and fold parseDedentedBlock back into parseAgentBlock,
along with the clearParseBlockCache export and its tests.
The per-agent avatar only appeared on embedded chats and quick-action
buttons, not in the sidebar/floating chat or header, so it was
asymmetric with the rest of the agent UI. Remove it entirely: the
per-agent avatarImage setting (type, settings UI, normalization), the
chat block `image:` option and its render path, the button avatar, the
backing image-resolver service and its test, the avatar CSS, and the
related docs and parser-test coverage.
The separate floating-button image is unchanged.
Add vitest suites for parseAgentBlock and resolveImageSrc, including the
path-traversal guard that shipped without coverage: chat/button parsing,
the lenient boolean spellings (true/false/yes/no/on/off/1/0 and bare
numeric 1/0), height normalization, every viewType alias, structural
errors, the parse cache's reference identity, URL/data passthrough, and
rejection of vault-escaping image paths (.. / absolute / drive-letter /
~). Extend the obsidian test stub with parseYaml, FileSystemAdapter, and
a ..-preserving normalizePath, and promote yaml to an explicit
devDependency.
Add a usage page documenting the fence languages, all chat/button
options, the device-local persistence model and id auto-injection, and
the validation and warning behavior, with a sidebar entry.
- Agent launcher: `cd "$1" 2>/dev/null;` -> `cd "$1" || exit 1;` so a failed
cd doesn't silently run the agent in the wrong directory. Matches the
terminal wrapper and pre-#304 launch (both use `cd ... && ...`).
- buildWslEnv: guard `existing` with `typeof baseEnv.WSLENV === "string"` so
the helper truly never throws (its docstring's contract) even if WSLENV is
set to a non-string via a type cast.
- Tests: add regression guards for both.
Not changed: the `/bin/sh -l -c` fallback for non-POSIX shells (Copilot
flagged it as possibly unsupported). It matches the shipped buildWslShellWrapper
and `wsl.exe /bin/sh -l -c 'echo OK'` works (dash supports -l); revisit across
all three builders together if a shell without `-l` support turns up.
The argv/terminal launchers added in 850e2e1 and b91184f dropped the
explicit `. ~/.profile 2>/dev/null;` that buildWslShellWrapper has. bash -l
skips ~/.profile when ~/.bash_profile exists, and linuxbrew/nvm/mise install
their PATH there -- and bare command names (e.g. claude-agent-acp) resolve
via that PATH. Without it, affected WSL users would fail to spawn the agent
or run terminal commands.
Restore `. ~/.profile 2>/dev/null;` in buildWslArgvScript and
buildWslTerminalScript, matching buildWslShellWrapper and preserving the
intent of #177 (236843c / dd4f4ca / fce40a3). Add regression-guard tests
asserting the launchers source ~/.profile.
Terminal commands (tool_call terminals) still used the nested
`sh -c "<buildWslShellWrapper(innerCommand)>"` construction, which carries
the same fragility that broke agent launch in some environments (e.g.
RHEL8) and double-escaped the command line.
Run terminal commands the same way as agents now:
wsl.exe [--exec] /bin/sh -c '<launcher>' sh '<commandLine>'
The command line is delivered as a single positional and run under the
user's login shell ($SHELL -l -c "$1"). This keeps full shell parsing
(pipes, redirects, subshells, bash-isms under the user's shell, e.g. bash)
-- behaviorally identical to the previous `$SHELL -l -c <line>` -- while
skipping wsl's default-shell layer (--exec) and removing the doubly-escaped
nested string.
Also, in WSL mode omit the Windows-side spawn cwd for terminals: the
working directory is applied inside the launcher (cd '<wslCwd>'), and a
Linux path as the wsl.exe process cwd would make CreateProcess fail.
buildWslShellWrapper is retained (now used only by paths.ts for the WSL
`which` lookup).
Tests updated/added accordingly (43 cases).
WSL agent launch previously wrapped the command in a nested
`sh -c "<baked string>"` construction. In some environments (e.g. RHEL8)
this fails before ACP connects (the process exits 1), and command
paths/args with spaces broke because the command was interpolated
unescaped.
Launch agents instead with:
wsl.exe [--exec] /bin/sh -c '<constant launcher>' sh <pathDir> <cwd> <command> <args...>
This skips wsl's default-shell layer (--exec), runs under the user's
login shell so ~/.profile is sourced (environment preserved, unlike a
bare --exec), and passes command/args as argv (no quoting of user data).
Also forward configured env vars (API keys, custom agent env, tool env)
into WSL via WSLENV (buildWslEnv), so the plugin's API key field works in
WSL mode without requiring keys in ~/.profile. Defensive: skips empty
values and invalid names, merges existing WSLENV, never throws.
Add a vitest unit-test suite for the platform WSL helpers (39 cases,
incl. exact argv-ordering checks), with an obsidian stub alias and a
dedicated tsconfig for typed linting of tests.
Builds on #304 (direct WSL exec for absolute-path commands); this retains
the environment that a bare --exec would drop and also fixes paths/args
with spaces.
Terminal launch keeps the existing wrapper for now (env forwarding is
applied); a terminal-specific hybrid follows separately.