Skip to content

feat(report-issue): bundle sanitized data.json in the Agent Mode issue report - #2677

Draft
zeroliu wants to merge 158 commits into
masterfrom
claude/bug-report-data-json-59rp8t
Draft

feat(report-issue): bundle sanitized data.json in the Agent Mode issue report#2677
zeroliu wants to merge 158 commits into
masterfrom
claude/bug-report-data-json-59rp8t

Conversation

@zeroliu

@zeroliu zeroliu commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

What

Adds a sanitized copy of the plugin settings (data.json) to the Agent Mode "Report an Issue" bundle, so maintainers can diagnose configuration-dependent bugs. Previously the bundle contained only report.md, the screenshot, the frame log, and the optional opencode log.

Behavior

  • New file in the bundle: the raw on-disk data.json (via Plugin.loadData(), so the report reflects what is actually persisted) is sanitized and written as data.json.txt — same .txt suffix workaround as the frame log, since GitHub's issue-attachment allowlist rejects .json.
  • Proactive redaction via a new pure sanitizeSettingsDataForReport() (src/utils/sanitizeSettingsData.ts). Sensitive values are masked as [REDACTED] rather than removed, so which providers/keys are configured stays visible. Covered:
    • everything matching the canonical isSensitiveKey() (all *ApiKey fields, plusLicenseKey, tokens, secrets, passwords), at any depth — including per-model apiKey in activeModels / activeEmbeddingModels
    • every value in any envOverrides map (backend env vars like AWS_ACCESS_KEY_ID don't match key-name patterns, so they're masked wholesale, key names kept)
    • infra identifiers (Azure instance/deployment names, org IDs, API versions), userId, and legacy enc_* encrypted strings wherever they appear
    • empty values pass through unchanged so "not configured" stays visible
  • Surfaced in the dialog: the "When you click Prepare report" list now states that a copy of your Copilot settings is included with secrets replaced by [REDACTED], and the privacy callout notes the copy still describes your configuration. The settings-page button description and report.md's attachment list ("data.json.txt (plugin settings; secrets redacted)") say the same.
  • Graceful degradation: if settings can't be loaded or written, the report is still produced without the snapshot (same best-effort pattern as the other bundle files).

Tests

  • New src/utils/sanitizeSettingsData.test.ts (10 tests) against a realistic disk-mode data.json fixture, including a catch-all assertion that no secret string appears anywhere in the serialized output.
  • Extended src/utils/issueReport.test.ts: snapshot written and listed when provided, skipped when null or on write failure, annotated in report.md.
  • Full suite: 4415 passing. npm run format, npm run lint, and tsc --noEmit clean.

Docs

  • docs/agent-mode-and-tools.md "Reporting an Issue" section updated, including the privacy note.

🤖 Generated with Claude Code

https://claude.ai/code/session_01W4ZjSKUSbWyWjx4CLeJS5v

zeroliu and others added 30 commits May 20, 2026 19:05
Squashed from 8 commits on zero/acp-test:
- feat(agent-mode): introduce Agent Mode feature (squashed)
- Fix eslint
- hide add context button
- chore: ban parent-relative imports and rewrite existing ones to @/ aliases
- wip(agent-mode): skills management + slash command revamp
- fix(agent-mode): initialize SkillManager before preload probes
- fix(build): align svgr jsxRuntime with tsconfig classic jsx
- fix(test): stub ItemView/WorkspaceLeaf in ChatSingleMessage obsidian mock
Bash tool calls collapse the command to 60 chars in the trail, which is
correct for noise but left the full command unreachable. Add an optional
`expandedDetails` hook on `ToolSummary` and implement it for Bash so
clicking the card surfaces the full command (with `# description`
prefix when present). Drop the `!inline` expand restriction so commands
inside an `AggregateCard` are individually expandable too.

Also adds an AGENTS.md rule requiring `cn()` around any Tailwind class
string that lives outside a literal JSX `className=` attribute so
`eslint-plugin-tailwindcss` actually lints them, and refactors
ActionCard's class composition to follow it.

Includes two unrelated micro-edits already in the working tree:
trailing-whitespace fix in chatModelManager.ts and a stale TODO removal
in AGENT_MODE_TODOS.md.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… GUI PATH (#2480)

macOS GUI apps launched from Finder/Dock inherit a minimal launchd PATH
(`/usr/bin:/bin:/usr/sbin:/sbin`) that omits Homebrew and user install
prefixes, so `which codex-acp` returned nothing and the Auto-detect button
falsely reported "not found." Augment PATH with the well-known prefixes
(`/opt/homebrew/bin`, `/usr/local/bin`) before invoking `which`, sharing
one source of truth with the existing spawn-time augmentation in
`nodeShebangPath`. For Claude, the Auto-detect button now routes through
the existing `resolveClaudeBinary` resolver (Volta/asdf/NVM/Homebrew/
npm-global/`~/.local/bin`) via a new optional `detect` prop on
`BinaryPathSetting`, matching the candidate list already used by the top
status row.
…2481)

New installs now start with Agent Mode on. Mobile is locked off at the
sanitizer level so a desktop-synced settings file can't activate the
unsupported subprocess flow.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Support agent image and PDF prompt context

* Use agent read for PDF context
* fix(agent-mode): preserve persisted enabled flag on mobile

Sanitization no longer force-overrides agentMode.enabled on mobile, so
a desktop-synced preference is not silently overwritten the next time
mobile persists settings. Mobile gating now lives at usage sites via a
shared isAgentModeEnabled / useIsAgentModeEnabled helper.

* fix(lint): drop unused Platform import in commands/index.ts

The remaining `Platform.isDesktopApp` site uses a dynamic import that
shadows the top-level binding, so the static import is unused after the
agent-mode gating refactor.
* feat(agent-mode): tint chat input border and mode picker by mode

Plan mode shows a blue border and soft glow; auto mode shows red.
The mode picker label picks up a matching faded color that brightens
on hover/focus, making the active agent mode obvious at a glance.

* fix(agent-mode): preserve mode tint on keyboard focus

The ghost2 button variant applies focus-visible:tw-text-normal, which
overrode the focus: tint on keyboard focus. Switch to focus-visible:
so tailwind-merge resolves the conflict in favor of the mode color.
…2485)

* fix(agent-mode): queue mode/model switch while session is still starting

* fix(agent-mode): disable picker while session is starting instead of queueing

Replace the await-session.ready queueing approach with a UI-level gate:
canSwitchModel/Effort/Mode now return false while status === "starting",
so the picker renders as disabled until the backend session is ready.
The await-during-onChange path made the picker feel buggy because clicks
appeared to do nothing for a beat before applying.

* fix(agent-mode): re-render mode/model picker on session status change

The picker hooks compute a memoization signal that React diffs to decide
when to rebuild the override. Status was not part of the signal, so the
starting → idle transition fired listeners but the snapshot string was
unchanged and the picker stayed disabled forever. Include `getStatus()`
in both signals so the picker re-enables once the session is ready.
Consolidates the three duplicated model configuration surfaces (basic
settings keys, Models settings table, agent mode curation) into a
single design for product designer handoff. Covers unified Models
panel, agent-first onboarding with three paths, per-backend defaults,
and OpenCode/Copilot Plus as system-managed sections.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hoist the agent-mode picker out of the not-generating branch so users
can flip between Auto/Plan/Safe mid-turn instead of having to wait for
the agent to stop. Non-agent chats are unaffected since they don't
pass modePickerOverride.
* Stop persisting agent mode selection

* Force canonical default mode on every new agent session

Restore `applyPersistedMode` as a small coerce helper and wire it back
into every backend's `applyInitialSessionConfig` with a literal
`"default"` — never reading from settings. Codex-acp's natural starting
native mode is `read-only` (canonical `plan`), so without this the
picker landed on Plan on every fresh chat.

For codex specifically, also pin the spawn-time approval/sandbox config
via `-c approval_policy="on-request"` and `-c sandbox_mode="workspace-write"`
so codex-acp's first `currentModeId` already matches the canonical `auto`
preset. Without these, the picker briefly flashed "Plan" before the
post-spawn coerce switched it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Drop redundant post-spawn mode coerce

The codex picker briefly flashing "Plan" was fixed at the spawn level
(`-c approval_policy/sandbox_mode` in CodexBackend). Claude's SDK and
opencode's spawn-time `default_agent` already start in canonical
`default`, so the runtime `applyPersistedMode(session, "default")`
calls on all three backends were dead defense-in-depth. Remove them
along with the now-unused helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Drop stale type-only import comment in settings/model.ts

`import type` is erased at runtime, so there's no actual cycle to
guard against — the keyword itself already signals "type-only" to
any reader.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…w-up (#2490)

- MODEL_MANAGEMENT_REDESIGN.md: BYOK is now the central, user-bring-only
  registry; drop per-model and per-provider Availability/Capability toggles;
  one global table with provider section rows; Quick Chat added as a 4th
  agent sub-tab; OpenCode/Plus models stay in the Agent panel only.
- QUICK_CHAT_AGENT_INTEGRATION.md (new): follow-up plan for elevating
  LangChain chat into a first-class agent backend (routing service, session
  adapter, v2->v3 settings migration, milestones Q1-Q6).
- AGENT_MODE_TODOS.md: mark shipped items; add notes for opencode/local
  models and subscription-based opencode flows.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the brief "Read Read" / "Edited Edit" flash that appeared while
a tool call's input JSON was still streaming. `targetFromTitle` now
returns an ellipsis when the title is just the vendor tool name (the
SDK's documented placeholder state), and a new `verb()` helper picks the
present-participle form for pending/in_progress calls and the past-tense
form for completed/failed ones — so cards read "Reading notes/foo.md" →
"Read notes/foo.md", "Fetching url" → "Fetched url", etc.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the modal-based permission prompter with inline ToolPermissionCard
rendered at the tail of the chat scroll, matching how plan proposals already
work. Refactor AgentSession status to be derived from underlying primitives
(resolver maps, abortController, etc.) so it cannot drift from reality, and
add tests covering the awaiting_permission lifecycle and turn-error reset.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an "Environment variables" editor to each Agent Mode backend
(Claude, Codex, opencode) so users can inject vars like
CLAUDE_CONFIG_DIR, CODEX_HOME, XDG_CONFIG_HOME, or HTTPS_PROXY without
polluting the parent shell. Overrides are merged onto process.env at
spawn (Codex, opencode) or per prompt() (Claude SDK), and persisted
through a shared sanitizer that enforces POSIX-style names, drops
control chars, and caps the record at 64 entries. The editor debounces
commits to avoid rewriting settings on every keystroke.

Also relocates BinaryPathSetting into src/agentMode/backends/shared/
to honor the agent-mode layer boundaries.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex (and other non-Claude agents) silently ignore the Claude-only
frontmatter flags (`disable-model-invocation`, `user-invocable`,
`model`), so hard-disabling their toggles on those skills was overly
restrictive. The inline chips on the row already communicate which
flags are Claude-specific.
`rowsToRecord` was persisting every non-empty trimmed key, so names
failing `ENV_VAR_NAME_RE` (e.g. `BAD KEY`, `FOO=BAR`) flowed through
`onChange` into settings and were applied verbatim by backend spawn
paths, which read `envOverrides` directly without re-sanitizing.
Filter invalid rows at the commit boundary so the persisted record
and subprocess env only contain valid POSIX identifiers.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agent-mode): teach backends about @-mention pill syntax

The chat editor serializes @-mention pills to `[[note]]`, `{folder}`, and
`{activeNote}` tokens, but only `[[note]]` was understood — the others
were literal strings to the agent. Add a shared pill-syntax directive
appended to opencode, codex, and claude SDK spawn-time instructions
(mirroring the existing skill-creation directive plumbing), and resolve
`{activeNote}` to the real `[[Note Title]]` at send time since the active
file is a client-side concept the agent can't query.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(agent-mode): prune narration comments from pill-syntax change

Cut multi-line JSDoc and inline rationale that explained WHAT or referenced
callers, per the project's "one short line max, only WHY" rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent-mode): preserve extension when resolving {activeNote}

Non-markdown active files (.pdf, .canvas) lost their extension when
{activeNote} was rewritten via activeFile.basename, leaving the agent
with an ambiguous [[name]] that didn't match the path the pill envelope
carries. Mirror NotePillNode.getTextContent's extension-aware
serialization so the inline token matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2503)

* feat(agent-mode): make note links clickable in chat and tool cards

Switches markdown rendering to the modern `MarkdownRenderer.render(app, …)`
API via a shared `renderMarkdown` wrapper, and adds a delegated click
handler so `a.internal-link` inside chat/agent/plan-preview views opens
via `app.workspace.openLinkText` (left-click in current pane, cmd/middle
in a new tab). Tool ActionCards now also expose the collapsed line as
a clickable target once the call has completed.

Threads `app` explicitly through the components and utilities that
previously relied on the global `app` (lexicalTextUtils, notePreviewUtils,
vaultPath, AtMention/Note/Paste/TextInsertion plugins) using the
`useApp()` hook at the leaf, and updates AGENTS.md to discourage new
uses of the global.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(chat): fix ChatSingleMessage mocks for wireInternalLinks

The `renderMarkdown` wrapper now calls `component.register(...)` after
`MarkdownRenderer.render(...)`. The test's `obsidian.Component` mock was
missing `register`, causing the promise to reject and `logError` (which
calls the unmocked `getSettings()`) to crash. Add `register` to the
Component mock and mock `getSettings` as a safety net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent-mode): resolve internal links against active note

Pass the active file path as sourcePath to renderMarkdown so wikilinks
with duplicate basenames or heading-only references resolve to the right
file instead of falling back to vault root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds OBSIDIAN_CLI_TEST.md with a field guide for driving the plugin
through the Obsidian desktop CLI: multi-vault targeting (the biggest
gotcha — default routes to whichever window was touched last), popout
window limitations, settings round-trips via eval/saveData, real-input
UI driving via dev:cdp, screenshots, console/error capture, performance
metrics, and a smoke-test scaffold. AGENTS.md now points to it and
keeps only the multi-vault warning inline so there is one source of
truth.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace non-existent `copilot:open-copilot-chat` with the registered
`copilot:agent-chat-open-window` ID in both the "Run a command" example
and the smoke-test scaffold so the documented commands actually work.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2507)

* perf(agent-mode): warm-adopt preload probe to skip first-chat spawn

The preloader's probe subprocess is now retained as a "warm" entry and
adopted by the first chat-open on that backend, so the first session
skips both the subprocess spawn and the `newSession` round-trip. Preload
status is also tracked per-backend: the chat gates on the active
backend's status only, and the picker shows per-backend loading rows for
the others.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(agent-mode): defer model+effort seeding to backend confirmation

- Seed only baseModelId (not effort) so descriptor-style backends
  (Claude) don't see a matching seeded effort and skip the real
  setConfigOption inside applyInitialSessionConfig.
- Gate warm-session `ready` on confirmSeededSelection so sendPrompt
  can't race onto the probe's model before setModel resolves.
- Add regression tests covering both paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…reshes (#2508)

* fix(skills): eliminate Skills Settings UI lag from watcher-driven refreshes

Replaces the time-based watcher suppression with path-scoped expectations:
each SkillManager FS write registers predicates (exists/missing/modified/
subtree-*) and the matching vault events are dropped without triggering a
debounced reconcile. A 10s safety timer backstops never-arriving events.
Toggle/delete/rename/saveProperties now publish incremental in-memory updates
instead of re-running full discovery, so the Properties modal save returns
synchronously and the grid reflects the change on the next render.

* fix(skills): heal drift when expectations expire; preserve external reconciles

Two fixes for the watcher-suppression machinery on PR #2508:

- `armSafetyTimer`: when the 10s backstop fires with expectations still
  pending, schedule a debounced reconcile before clearing. Catches the case
  where an external process undid our change (e.g. deleted a link we expected
  to exist) — the matching event was suppressed, predicate never satisfied,
  and previously the state drifted silently until an unrelated watched event
  fired.

- `runInternalMutation`: stop cancelling pre-existing reconcile timers in
  the finally block. Events fired during the mutation are dropped by the
  depth gate, so any debounce timer left armed was scheduled by an external
  event before the mutation started and still needs servicing.

Adds two regression tests and updates the pre-existing "safety timer clears
stale expectations" test which is superseded by the new healing behavior.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2513)

Replaces the rescan/import-consent flow with unified discovery across
canonical and agent project folders, lazy migration with a global
suppress flag, and an overflow-menu lockdown for mirrored duplicates.
…sh TODOs (#2514)

- Add `.claude/skills/create-agent-issue/SKILL.md`: project-level skill that
  codifies the standard for one well-formed GitHub issue (title shape, body
  sections, label palette, draft artifact format, reviewer rubric, three-stage
  workflow: draft / review-and-push / all).
- Add `designdocs/todo/AGENT_TODOS_TO_ISSUES_PLAYBOOK.md`: orchestrator
  playbook for migrating `AGENT_MODE_TODOS.md` into GitHub issues with a
  two-subagent (author + reviewer) flow, autonomous triage, parallelism,
  idempotency, and verbatim subagent prompt templates.
- Revamp `designdocs/todo/AGENT_MODE_TODOS.md`: richer per-item context,
  "design needed" markers, reorganized MCP and Codex auth items, current
  pending count is 33 top-level items (P0=7, P1=17, P2=7, P3=2).
- Drop `designdocs/todo/MCP_EXTERNALLY_MANAGED_SERVERS.md` (stale).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces every pending top-level `- [ ]` in `AGENT_MODE_TODOS.md` with a
link to its corresponding issue in logancyang/obsidian-copilot-preview
(#59-#98). Follows the playbook in `AGENT_TODOS_TO_ISSUES_PLAYBOOK.md`:
sub-bullets folded into issue bodies, and "Integrate copilot plus tool
calls" split into 5 child issues (#80-#84) under a now-plain parent
bullet. 39 issues opened end-to-end via paired author/reviewer subagents.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the per-backend install/settings panels with a shared Configure
dialog layout. Introduces ConfigDialogShell + ConfigSection, InstallCommandRow,
and an installStatus badge/status-line module (with tests), and reworks the
Claude, Codex, and opencode Configure dialogs onto it. The settings page now
shows each backend's icon, an install-status badge, and a Configure button,
dropping the old BinaryInstallContent and SimpleBackendSettingsPanel helpers.
Copy is simplified for non-technical users and dialog spacing/dividers are
made consistent.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
logancyang and others added 24 commits June 21, 2026 20:40
* feat(agent-mode): @-mention agent selection in composer (phase 1/5, #57)

Add an "Agents" group at the top of the existing @-typeahead so users can
@-mention installed coding agents (e.g. @claude / @codex / @opencode) to ask
the same turn of several agents. The group is registry-driven and installed-
only: it lists every backend whose install state is `ready`, so newly
registered backends are mentionable automatically with no per-agent branches.
Selections render as removable agent pills (reusing the shared Lexical pill /
typeahead rendering) and resolve at send time into a structured
`mentionedAgents: BackendId[]` (main agent first, deduped if re-mentioned),
never left as literal text in the prompt.

The host chat-components stay agent-agnostic: a local `AgentMentionBrand` shape
is passed down as props, so the generic composer never imports Agent Mode
internals. `mentionedAgents` threads through sendMessage -> sendPrompt to a
typed seam, `AgentSession.getLastMentionedAgents()`, that later phases consume;
the single-agent path (no mentions) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): address review on @-mention agent selection (phase 1/5, #57)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent-mode): fan-out orchestration + read-only enforcement (phase 2/5, #57)

When a turn has more than one agent (main + ≥1 @-mentioned), runTurn now
dispatches the identical prompt + context to every backend in parallel via
ephemeral read-only sub-sessions instead of the single backend.prompt(). The
single-agent path (0 mentions) is unchanged.

Each sub-session is created on its own backend (ensureBackend + newSession),
never registered as a visible AgentSession / tab, runs the agent's configured
default model, and is closed at turn end. Read-only is enforced three ways:
a universal "answer only, no writes" prompt preamble; a shared permission
prompter that hard-denies write/exec tool kinds (edit/delete/move/execute) and
allows reads/search/fetch for fan-out sub-sessions on ALL backends
(claude + codex + opencode); plus a per-backend read-only sandbox mode for
setMode-style backends (codex plan→read-only) as belt-and-suspenders.

Answers stream live into per-agent slots of a single in-memory FanoutTurn; a
failed agent sets its slot to error and the others continue (allSettled-style),
and sub-session prompts are cancellable via the turn's abort signal. The
summary slot is left pending for Phase 3. Persistence is no-migration: a
fan-out turn collapses to summary-only text written as an ordinary assistant
message, so per-agent answers are never serialized and existing single-agent
transcripts load unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): address review on fan-out orchestration + read-only enforcement (phase 2/5, #57)

Classify NotebookEdit as an edit tool kind so the read-only fan-out
permission policy denies it. Previously NotebookEdit (a native Claude
SDK .ipynb write tool) fell through deriveToolKind to "other", which
isWriteOrExecToolKind allows, letting a fan-out read-only sub-session on
the Claude SDK backend perform a notebook write. Adds a guard test over
every native write/exec tool so this class of gap cannot regress.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent-mode): main-agent narrative summary for multi-agent QA (phase 3/5, #57)

Once every fan-out answer settles, the session's main agent generates a
narrative summary that reconciles and contrasts the per-agent answers,
streamed token-by-token into the live FanoutTurn's summary slot (status
pending -> streaming -> done) for Phase 4 to render.

The summary runs read-only via the Phase 2 mechanism: per-agent answers
and the summary now share a single runReadOnlySubSession helper that opens
an ephemeral sub-session, registers it as read-only (hard write/exec
denial), applies the sandbox mode + default model, streams assistant text,
and tears down. The summary feeds the main agent a NEW composed user-turn
prompt (read-only instruction + the user's original question + each
succeeded answer labeled by display name); no existing system prompt is
touched.

Failure-aware (D7): the summary runs over the agents that succeeded and
names any that failed or returned empty. With zero successes it does not
fabricate a summary -- it lands summary.status="done" with a brief
all-failed note (the chosen zero-success terminal state), and dispatches no
sub-session. Cancellation skips the summary.

Persistence is unchanged: the collapse-to-summary-only path from Phase 2
now writes the generated summary text to disk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): address review on multi-agent QA summary (phase 3/5, #57)

Correct doc comments that this commit makes stale: the summary slot is now
filled by the main agent over the survivors (D6) once every answer settles,
not left pending for a future phase. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent-mode): summary-first dropdown UI for multi-agent QA (phase 4/5, #57)

Render a live multi-agent FanoutTurn as one assistant turn: a summary-first
Select switcher (D8) that drills into the main agent's narrative summary or
each participating agent's full answer. Each agent entry reflects its live
state (D7) — spinner while streaming, the answer when done, an error state on
failure — and updates per token as the orchestrator streams into the live
turn.

The fan-out view renders only for the active/live turn (the last assistant
message while AgentSession.getLiveFanoutTurn() is non-null); normal
single-agent assistant messages and reloaded summary-only transcripts render
exactly as before through the existing path. Branded icons + display names are
registry-driven (backendRegistry), and answers/summary reuse the existing
AgentMarkdownText renderer.

The live turn is surfaced through AgentChatBackend.getLiveFanoutTurn() and the
useAgentChatRuntimeState subscription, so only the memoized FanoutTurnView
re-renders on the streaming hot path. Pure option-list derivation and
status-to-state mapping live in fanoutDropdown.ts with unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): address review on multi-agent QA dropdown UI (phase 4/5, #57)

The orchestrator mutates one FanoutTurn in place and re-emits the SAME
reference per streamed token, so the Phase 4 UI subscription handed that
identity straight into React state — setState bailed (Object.is-equal) and
the dropdown froze on its first frame, never reflecting live per-agent
streaming/status updates. getLiveFanoutTurn() now returns a structural
snapshot so each coalesced notify yields a fresh reference and re-renders.

Also clear a prior turn's live fan-out state BEFORE the next turn's
placeholder is announced (was cleared after notifyMessages fired), so a
following single-agent turn's placeholder can no longer briefly render the
stale dropdown.

Adds snapshotFanoutTurn unit tests and a cross-turn no-leak session test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent-mode): partial-failure & cancellation hardening for multi-agent QA (phase 5/5, #57)

Harden the two failure modes of the multi-agent read-only QA fan-out so the
feature is robust end-to-end:

- Partial failure (D7): one agent's rejection/throw was already isolated to its
  own error slot; add a per-agent answer TIMEOUT so a hung/long-running
  sub-session fails its OWN slot (error + reason) without stalling the siblings
  or the summary. Duration is a single documented constant
  (FANOUT_AGENT_TIMEOUT_MS, 5 min); no user-facing setting. The summary still
  runs over the survivors.
- Cancellation: cancelling the turn aborts every in-flight sub-session promptly
  (proc.cancel via the run signal) and an abort mid-prompt now transitions the
  slot to a terminal "cancelled" state instead of being mislabelled "done" or
  left "running". The sub-session is closed in finally on every exit path
  (done / aborted / timeout / throw) so no ACP sub-session leaks, and no summary
  is generated after cancel.
- UI: add a distinct "cancelled" agent state (muted slash chip) alongside the
  refined error chip (short reason, incl. timeouts); both preserve any partial
  text streamed before the stop. Registry-driven, reuses existing tokens.

Tests: one agent rejects -> error slot, others complete, summary over survivors;
per-agent timeout fires -> that slot errors, turn + summary still finish; cancel
mid-flight -> every in-flight sub-session gets cancel, slots reach terminal
cancelled, no summary after cancel; UI maps error + cancelled states.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): address review on partial-failure & cancellation hardening (phase 5/5, #57)

Clear the per-agent deadline timer (and remove the abort listener) on the
abort path of runPromptWithTimeout. Previously onAbort resolved "aborted"
without calling cleanup(), leaving a live 5-minute setTimeout (holding proc /
sessionId / prompt) running after the user cancelled the turn. Move cleanup()
into both the onAbort and timeout callbacks so every settle path tears down
both the timer and the listener exactly once.

Add a regression test asserting the deadline timer count is 0 after an abort
and that advancing past the deadline never relabels a settled cancelled slot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): clear stale agent mentions on session switch + terminal summary states (#57)

Address Codex review on PR #2628:

- P2: reset `mentionedAgentIdsRef` in the session-switch effect so an
  `@agent` pill composed in one session cannot ride along into an
  unrelated prompt in another (the ref lives in AgentChatInput, not the
  keyed ChatInput, so the editor remount does not clear it).
- P3: render terminal summary states instead of a perpetual spinner.
  A cancelled turn leaves the summary `pending` (runSummary skipped) and
  a failed summary lands `done` with empty text; both now show a
  "Summary cancelled" / "Summary unavailable" line via the new pure
  `summaryDisplayState` helper, unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): carry fan-out Q+summary into the next single-agent turn (#57)

A fan-out turn runs every agent through ephemeral read-only sub-sessions and only
writes the summary to the store; the visible session's backend never sees the
turn, so a normal single-agent follow-up loses that context. Buffer each
completed fan-out turn's {question, summary} on the session and inject the
buffered entries (in order) as one labeled <prior_turns> context block ahead of
the user message on the next single-agent prompt, then clear. Consecutive fan-out
turns accumulate; only single-agent turns flush. Backend-agnostic; live-only, no
persistence change. Empty buffer leaves the prompt byte-for-byte unchanged.
Addresses Codex P1 on PR #2628.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): address review on fan-out follow-up continuity (#57)

Clear the pending fan-out buffer only AFTER backend.prompt() resolves,
not before. The block is captured into promptBlocks pre-await, so the
prompt sent is unchanged, but a thrown prompt now preserves the buffer
so the dropped multi-agent QA context replays on the next single-agent
turn instead of being lost with no record. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): pill text leak, read-only plan-file bypass, blank summary persistence (#57)

E (pill text leak): AgentPillNode extended BasePillNode without overriding
getTextContent(), so the editor serialized the backend id into the prompt
(`@Claude …` became `claude …`). Override getTextContent() to return "" — the
mention already feeds mentionedAgents structurally and the visual pill comes
from decorate(). Other pill types are unchanged.

C (read-only fan-out could still write Claude plan files):
- (a) Stop abusing canonical.plan as the read-only sandbox. Add an optional
  ModeMapping.readOnlyModeId for a backend's GENUINE read-only sandbox; set it
  only on Codex ("read-only"). Claude/OpenCode leave it unset. FanoutOrchestrator
  .applyReadOnlyMode now applies readOnlyModeId (skipped when absent) instead of
  canonical.plan, so a read-only QA turn never enters Claude's real plan mode
  (which drafts and writes plan files). Registry-driven, no name branch.
- (b) Make the read-only-session check precede the plan-file auto-allow in the
  Claude SDK permissionBridge. The bridge now consults a read-only-session
  predicate (threaded generically via the optional BackendProcess
  .setReadOnlySessionPredicate, wired from AgentSessionManager.isReadOnlyFanout
  Session) at the TOP of canUseTool and hard-denies write/exec tools (reusing
  isWriteOrExecToolKind) BEFORE the plan-file auto-allow — closing the hole even
  if a mode switch is wrong for some backend.

A (summary failure persisted a blank assistant message): when summary generation
threw/ended empty but agents answered, the turn collapsed to "" and reloaded as
a blank bubble. collapseFanoutTurnToSummaryText now returns a new
FANOUT_SUMMARY_UNAVAILABLE note whenever at least one agent succeeded but no
summary text exists; zero-success still uses FANOUT_ALL_FAILED_SUMMARY (written
by runSummary); a turn with no successes and no summary collapses to "" so
nothing misleading is persisted. Per-agent answers remain live-only.

Tests: agent pill contributes empty text + prompt omits backend id; read-only
session denies a ~/.claude/plans/*.md Write before the auto-allow; applyReadOnly
Mode applies readOnlyModeId (never plan) only when advertised; summary-throws-
with-answers persists the fallback, zero-success uses the all-failed note, a
normal summary is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent-mode): give fan-out agents the conversation history (#57)

Fan-out QA dispatched every agent to a FRESH ephemeral session that
received only the current turn's prompt, so `@agent` follow-ups that
reference earlier conversation ("that plan", "the answer above") and
fan-out→fan-out continuity failed (Codex finding D on PR #2628). This
realizes D9 of the fan-out design: render the prior visible transcript
into a read-only `<conversation_history>` block and prepend it to every
fan-out agent's prompt, ahead of the current question + context.

- New pure `buildConversationHistoryBlock(messages, maxChars)` in
  fanoutTypes.ts renders prior display messages labeled by role, escaped,
  framed as read-only context (not a task to redo). Returns null on empty
  history so the prompt stays byte-for-byte unchanged.
- Oldest-first safety cap (`FANOUT_HISTORY_MAX_CHARS` = 48000) with a
  truncation marker: fan-out sub-sessions are single-turn, so the whole
  transcript rides in one prompt with nothing to compact; an oversized
  block would hard-error the model API rather than auto-truncate.
- Injection reuses `buildPromptBlocks`'s `leadingContextBlock` seam (same
  as the single-agent prior-fan-out path); the current turn is excluded
  by the exact user/placeholder ids `sendPrompt` captured.
- The block is backend-prompt only — never displayed or persisted — and
  every agent (main + mentioned) gets the identical block (D10). Separate
  from the existing single-agent pending-fan-out buffer, which stays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): apply user model+effort to fan-out sub-sessions (#57)

FanoutOrchestrator.applyDefaultModel applied only the encoded model id to
each ephemeral sub-session via setSessionModel. For backends where effort
travels via a config option rather than the wire id (Claude SDK: claudeWire
drops effort, effortConfigFor exposes the select spec), the sub-session
never received the user's configured effort and ran at the backend default.

Mirror descriptor.applySelection generically off the wire codec: after
setSessionModel, when selection.effort is set and
descriptor.wire.effortConfigFor(baseModelId) returns a spec, apply effort
with setSessionConfigOption. Wire-encoded-effort backends (codex/opencode
suffix style) return nothing from effortConfigFor and need no extra call,
so there is no per-agent-name branching. Kept best-effort (try/catch with
logWarn) so a missing/unsupported option leaves the backend default in
place. The opencode config-option-model catalog case (where set_model
itself is gone) remains a documented residual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): address review on fan-out model+effort apply (#57)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): bound fan-out history cap and include tool/plan parts (#57)

Two fixes to the pure buildConversationHistoryBlock used to inject the prior
visible transcript into every fan-out QA sub-session.

Finding 1 (real cap): the oldest-first drop loop stopped at a single turn, so
one giant recent message (a long answer or pasted dump) passed through
uncapped and risked a prompt-too-large error in the fresh sub-session. Add a
final hard cap: after dropping older turns, if the joined body still exceeds
FANOUT_HISTORY_MAX_CHARS, truncate its head and append a "[turn truncated]"
marker. The rendered block body is now bounded by ~maxChars plus the small
constant framing/markers for ANY input, never unbounded, never empty when
there is at least one non-empty turn.

Finding 2 (fidelity): the builder rendered only prose and dropped turns whose
prose was empty, so a follow-up like "explain the command output above" or
"review the plan above" got no context that single-agent backend memory would
have carried. Serialize each turn's renderable parts off part kind (no
per-agent branching): prose from message (which already aggregates the text
parts, so no duplication), tool_call parts as "[tool: <identity>]" plus their
text output (trimmed per part so one card can't dominate), and plan parts as
their entries. thought parts are omitted. A turn with any renderable content
(prose OR tool/plan parts) is no longer dropped; only truly empty turns are
skipped. All content stays XML-escaped so it can't break the framing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): settle timed-out fan-out prompts before reusing the backend (#57)

On a per-agent prompt timeout/abort the orchestrator called proc.cancel and
resolved the slot immediately, but cancel only INTERRUPTS — the underlying
proc.prompt() keeps unwinding the backend query. Since run() reuses the main
agent's backend for the summary, the summary's prompt could start on the same
backend while the main agent's timed-out prompt was still unwinding. The Claude
SDK backend's permission-bridge/session context is process-global for the
active query, so two overlapping prompts on one backend can misroute permission
decisions/events or corrupt the summary.

runPromptWithTimeout now holds the real prompt promise and, on the abort AND
timeout paths, awaits its actual settlement (swallowed) before resolving the
helper — so "settled" means the backend query truly stopped, not just that
cancel was requested. That wait is bounded by a new FANOUT_CANCEL_GRACE_MS (3s)
constant so a backend that ignores cancel cannot hang the turn forever (it logs
and proceeds, preserving the timeout's bounded wall-clock). The happy path
(prompt resolves on its own) never enters the grace. Applied uniformly to every
sub-session, not special-cased to the main agent.

Preserves the single-shot settled guard, clears the deadline + grace timers on
all paths (no timer leak), keeps the swallowed prompt rejection from surfacing
as unhandled, and keeps abort terminal-cancelled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): address review on timed-out prompt settle (#57)

The settle-wait awaited the swallowed prompt via a bare `promptPromise.finally`
whose derived promise was only `void`-ed. `.finally` re-raises the original
rejection, and a cancelled/timed-out backend prompt usually REJECTS as it
unwinds, so that branch leaked an unhandled rejection (the up-front
`promptPromise.catch` only guards its own branch, not the `.finally` chain).

Replace it with a `promptSettled = promptPromise.then(noop, noop)` that maps
both outcomes to a resolved value; the cancel grace awaits `promptSettled`, so
the swallowed rejection can never surface as unhandled while settlement
semantics (fulfilled OR rejected both count as "the backend query stopped")
are unchanged. Add a regression test that rejects the cancelled main prompt and
asserts no unhandled rejection plus the summary still reuses the backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): preserve replay buffer on cancel + route fan-out model via config-option channel (#57)

Finding 1 (UX continuity): the post-fanout single-agent replay cleared the
pendingFanoutContext buffer whenever backend.prompt() resolved, including a
CANCELLED resolution. A user stopping that first replay mid-stream could lose
the multi-agent <prior_turns> context because the backend may not have durably
ingested the injected block. The clear is now gated on
resp.stopReason !== "cancelled", so a cancelled replay keeps the buffer and
re-injects on the next turn (a duplicate re-injection is harmless). The
empty-buffer byte-for-byte path and the thrown-prompt-preserves-buffer behavior
are unchanged.

Finding 2 (UX correct model): FanoutOrchestrator.applyDefaultModel always
applied the QA sub-session's model via setSessionModel, which hits the now-gone
session/set_model RPC on opencode >= 1.15.13 (where the catalog is a
category:"model" config option) and silently left the sub-session on the
backend default model. The orchestrator now threads the opened sub-session's
BackendState.model.apply spec from newSession and, for a setConfigOption spec,
applies the model via setSessionConfigOption (and effort via the model-specific
effortConfigId reported by the refreshed state), mirroring
AgentSession.applyModelWireId + the opencode descriptor.applySelection. The
setSessionModel backends (claude/codex) are unchanged. This resolves the
opencode >=1.15.13 model residual. Channel selection is driven off the apply
spec, with no per-agent-name branching, and remains best-effort (failures are
logged and leave the backend default in place).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): capture trailing ACP chunks before closing fan-out sub-sessions (#57)

Some ACP backends (opencode, fast models) flush a turn's final
`agent_message_chunk` events just AFTER the `session/prompt` result
resolves. Fan-out sub-sessions previously unregistered their per-session
update handler immediately once `prompt()` resolved, so those trailing
chunks arrived after the handler was gone and were dropped, truncating a
fan-out agent's answer or the main-agent summary right at the end.

On the normal resolve path only, hold the still-registered handler open
for a short bounded grace (FANOUT_TRAILING_CHUNK_GRACE_MS = 500ms) before
unregistering and closing the ephemeral sub-session, so late chunks still
land in the same slot. Cancel/timeout/throw skip the grace entirely,
mirroring the single-agent "except on explicit cancel" carve-out where
suppressed output stays suppressed. The grace timer is one-shot and
cannot leak; the existing cancel-settle grace, single-shot guard,
deadline-timer cleanup, no-unhandled-rejection handling, and terminal
slot states are all preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): mark prior image attachments in fan-out history (#57)

The fan-out conversation-history renderer built each prior turn's block from
prose (message.message) and tool/plan parts only, ignoring message.content —
the display array that carries image attachment blocks. As a result an
image-only turn (empty prose, no parts) rendered as null and was DROPPED from
history entirely, and a text+image turn lost any signal the image existed, so
an @agent follow-up like "what's in the screenshot above?" reached the fresh
fan-out sessions with no hint of the image.

renderTurnContent now counts image attachment blocks in content (defensively
narrowed: a non-null object whose type is "image" or "image_url") and appends a
concise marker, e.g. "[1 image attachment omitted from history; ...]"
(singular/plural correct). An image-only turn now renders the marker and is no
longer dropped; a turn with no image content is byte-for-byte unchanged. This
only signals that omitted image context existed — threading the actual image
bytes into the prompt (full multimodal history) is a tracked follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): include prior-turn context (selections/notes/tabs) in fan-out history (#57)

The fan-out conversation-history renderer (buildConversationHistoryBlock /
renderTurnContent in fanoutTypes.ts) serialized each prior turn's prose,
structured parts, and image markers but never message.context. Fan-out agents
run fresh sessions with no memory, so any visible context a prior turn attached
(selected-text excerpts, notes, folders, web tabs, urls, tags) was lost — a
follow-up like "@codex explain the selected excerpt above" reached them with no
excerpt, even though the single-agent backend had seen it inline.

renderTurnContent now appends a [context] section rendering the stored context:
selection excerpts (label + actual content, per-item trimmed so one can't
dominate) plus concise identifier lines for notes/folders/urls/tags/web tabs.
Empty/undefined context renders nothing (byte-for-byte unchanged); a
context-only turn is no longer dropped. Dynamic values are escaped by the
existing outer escapeXml pass, matching the tool/plan/image renderers.

Full note/web-tab bodies are read from the vault at prompt time and are not
stored on message.context, so only note NAMES + the already-captured selection
excerpts are included; threading full note bodies is a tracked follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): preserve note paths and web-tab URLs in fan-out history

Fan-out history rendered notes by basename and web tabs by title only,
so a fresh fan-out sub-session asked to read the note above or check the
page above had no resolvable path/URL. Render n.path and keep the URL
alongside each tab title so the actionable identifier survives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* feat(agent-mode): main agent summarizes only; answerers are the @-mentioned set

Pre-ship redesign of multi-agent per-turn QA. The session's main agent no
longer auto-joins the answerer list: answerers are the deduped, installed
@-mentioned agents (it answers only if explicitly @-ed), and the main agent
always writes the summary. Fan-out fires for >=1 answerer except a lone
[main], which collapses to the normal single-agent path. The summary always
runs, even for a single answerer.

Pure routing helpers (resolveAnswerers/isFanout/EMPTY_ANSWERERS) live in a
UI-free fanout/answerers.ts so the session and composer share one source of
truth without a session->ui dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* feat(agent-mode): persist multi-agent response on the message object with a tabs UI

The AI response message now owns its fan-out sub-responses. A live-only
message.fanout drives the UI; the full composite is persisted as the message
body itself - composite markdown with invisible HTML-comment section markers -
so reload reconstructs the dropdown and any unaware loader/export still renders
clean markdown. No new on-disk field, no migration; marker-less bodies parse to
null and render exactly as before.

serializeFanoutComposite/parseFanoutComposite round-trip losslessly (a
self-disambiguating two-symbol escape prevents marker forgery AND preserves
answer text that contains the escape sentinel); each persisted answer is capped.
The store setFanout bumps the message version so the streaming dropdown
re-renders without freezing.

FanoutTurnView is now a segmented tab row (Summary first/default, brand icon +
live status dot per tab) with a per-slot inline copy. The new FanoutMessageCard
reuses the existing Agent-Mode AI action bar (Insert/Replace + Copy, no Retry)
acting on the whole composite. The liveFanoutTurn side-channel is retired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* feat(agent-mode): gate the @agent typeahead to paid users (paywall layer 1)

Multi-agent per-turn QA is paid-only. Free users get the frozen
EMPTY_AGENT_MENTION_BRANDS, so the @-mention 'Agents' group never renders and
no agent pill can be inserted (typeahead, paste/text-parse, and draft-restore
paths all verified closed). The gate is reactive via useCanUseMultiAgent(), so
it flips live on upgrade/downgrade; paid users hit the unchanged path. A subtle
conditional upsell hint above the composer points free users to upgrade
(navigateToPlusPage with a new MULTI_AGENT UTM medium).

canUseMultiAgent()/useCanUseMultiAgent() mirror isPlusEnabled()/useIsPlusUser()
respectively; their intentional sync-vs-reactive delta on the unvalidated
self-host edge is documented. A determined free user pasting agent-pill HTML is
caught by the send-time backend check in the next phase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* feat(agent-mode): authoritative send-boundary paywall for multi-agent QA (layer 2)

The session boundary is the unbypassable gate: in runTurn, before any fan-out
work, ensureMultiAgentEntitlement() must pass. Paying users hit a sync
isPlusEnabled() fast path with zero network cost; otherwise it re-verifies
against the backend /license endpoint (covering a stale-false cache for a real
paid user) and blocks on a negative or unverifiable result. A blocked turn is a
hard stop - no silent single-agent fallback - that shows an upgrade prompt
(Notice + the MULTI_AGENT upgrade page), marks the placeholder as an error,
settles as 'refusal', and returns the session to idle so the user can resend.

This catches any agent pill that slips past the UI gate (e.g. pasted pill HTML).
app is threaded via getApp DI, never the global.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): resolve send-time mentions against the real installed set, not the paywalled list

The @-mention typeahead list is entitlement-gated (empty for free users), but it
was also reused as the send-time installed-agent allowlist. So a pasted/imported
agent pill (or a stale-false Plus cache) resolved to answerers=[], omitted
mentionedAgents, and silently ran single-agent - never reaching AgentSession's
authoritative entitlement gate. Resolve installed backends independently of the
gated UI list so such turns fan out and hit the session gate, which blocks or
re-verifies. Fixes Codex P2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): make the fan-out summary concise and answer-focused

The summary instruction asked for a reconcile-and-contrast narrative with no
length bound, so it ballooned and analyzed the environment scaffolding (tool
lists, skill/agent catalogs) that agents dump into their answers. Rewrite it to
give one direct, concise answer scaled to the question, ignore that scaffolding,
and stop narrating each agent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): one context-aware copy/insert for fan-out, drop the per-slot row

The per-slot copy sat in its own full-width hover row above each answer, and the
action bar separately copied the whole composite - redundant and visually heavy
on a single-agent tab. Collapse to ONE affordance: the card's action bar
Copy/Insert now act on the tab in view (summary tab copies the summary, an agent
tab copies that agent - copy what you see). FanoutTurnView becomes controlled so
the card owns the selected tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): persist a terminal fan-out agent's partial text on reload

A cancelled/errored agent that streamed partial text shows it in the live tab
(FanoutTerminalState), but serialization wrote every non-succeeded slot as a
body-less 'did not answer' marker, so autosave/reload lost output the user saw
(and an all-terminal turn could reload with no agent bodies). Persist non-empty
terminal text (with its status, and the error reason in an error attr) so reload
matches the live tab; truly empty slots still get the body-less note. The summary
still excludes terminal slots. Reverses the earlier deliberate drop-partial
choice in favor of live/reload consistency. Fixes Codex P2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): summary must report the agents' answers, not answer as itself

The prior instruction said 'give a direct answer to the question', so for a
first-person question (e.g. 'what model are you') the summarizer answered about
ITSELF instead of synthesizing the agents' answers. Reframe it as a third-party
reconciler: it summarizes what THE AGENTS said, never substitutes its own
identity/opinion, attributes claims when it matters, and stays concise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): restore whole-composite copy/insert via the Summary tab

The minimal copy redesign dropped any way to copy the whole turn. Bring it back
without the old redundant top row: the single context-aware action bar now
copies/inserts the WHOLE composite on the Summary tab (the default, so copy-all
is one click) and just the selected agent on an agent tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): escape > in fan-out marker attributes

Backend-controlled error text (now persisted in an error= marker attribute) can
contain >, which terminated the agent marker early because parseFanoutComposite
matches with agent[^>]*, corrupting the reloaded turn. Neutralize > (like the
existing -- and " escapes) so such markers always parse. Fixes Codex P2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): replay the answers when a fan-out summary is unavailable

When the summary failed/cancelled but agents answered, only the generic
'summary unavailable' note was buffered for single-agent continuity, so a
follow-up like 'what did they say?' lost the per-agent answers the user saw and
that are persisted in the composite. Replay the readable answers
(renderFanoutComposite) in that case; a real summary is still preferred when
present. Fixes Codex P2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): persist partial answers on cancel; tighten summary prompt boundaries

Two fixes to fan-out content fidelity and the summary:

- Persist-on-cancel (Codex P2): the content gate only counted summary/succeeded
  text, so a turn cancelled mid-stream (partials, no done slot) saved nothing
  even though serializeFanoutComposite can now persist partials. Count any
  non-empty slot text, and include terminal partial text in renderFanoutComposite
  so the composite, copy-all, and single-agent replay all match the live tabs.

- Summary boundaries: the summarizer kept adding meta ('the agents converge…',
  'only environment scaffolding', 'Note: …'). The instruction now forbids
  mentioning agent counts, who did/didn't answer, scaffolding, or any caveats,
  and buildSummaryUserPrompt no longer tells it to 'note this gap' — failed
  agents are omitted from the prompt entirely so it can't mention them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): use the sigma thinking spinner in the fan-out response area

The body's thinking/streaming/waiting/writing states used a generic Loader2
ring; swap them to the app's animated sigma (Σ) CopilotSpinner so multi-agent
matches the thinking/reasoning indicator everywhere else. The tab status dots
keep their compact icons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): bound fan-out per-agent setup against cancel and timeout

Only the per-agent prompt was raced against cancel + the timeout; the SETUP
phase (ensureBackendForFanout, newSession, read-only-mode/default-model
round-trips) was not, so a cold or wedged backend whose session/new never
returned could hang the whole fan-out turn with Stop unable to settle it.

Generalize runPromptWithTimeout into runAttemptWithTimeout, bounding the ENTIRE
attempt (setup + prompt) by one FANOUT_AGENT_TIMEOUT_MS deadline + the abort
signal. A wedged setup now settles promptly: cancelled on abort, errored on
timeout, without awaiting the stuck promise. Teardown stays in the attempt's
single finally so a late-resolving newSession is still cancelled (no orphan
sub-session); timer + abort listener are cleared on every settle path; the
prompt-phase cancel grace (FANOUT_CANCEL_GRACE_MS) and trailing-chunk handling
are preserved (and now correctly skipped on the timeout path too). Fixes Codex P2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): high-quality, strictly-attributed fan-out summary prompt

The summarizer kept answering in the first person ('I am powered by…') because
the agents' answers are first-person and it mirrored them. Rewrite the summary
instruction as a structured spec: strict THIRD-PERSON attribution by agent name
(explicitly converting the agents' 'I/my' into '<agent> says it…', no sentence
may begin with 'I'); per-agent / agreements / disagreements sections for two or
more answers, degrading to a short attributed summary for a single agent; and
the existing scope guards (ignore scaffolding, never mention counts or
non-answerers). Uses generic placeholder examples, no hardcoded model/agent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): settle a pre-aborted fan-out turn before launching agents

If Stop is pressed during the async work before fan-out dispatch (license
re-verify, web-tab serialization), the signal is already aborted when
runAttemptWithTimeout runs; the abort listener it arms never fires for an
already-fired signal, so agents still opened sub-sessions and dispatched
read-only prompts, only marked cancelled after they finished/timed out. Check
signal.aborted right after arming the listener and settle via the same cancel
path without starting the attempt, so no sub-session opens and no prompt runs
after Stop. Fixes Codex P2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): replay answers after an interrupted/partial fan-out summary

The continuity buffer preferred summary.text whenever it was non-empty, but a
summary that streamed partial text then was cancelled or errored is incomplete —
runSummary's finally forces status to 'done' regardless, so status couldn't tell
them apart, and a follow-up like 'what did they say?' got only the partial
summary while the full answers sat unused in the composite.

Add FanoutSummary.complete (live-only), set by runSummary ONLY on a clean finish
(or the all-failed terminal). The replay trusts summary.text only when complete;
otherwise it falls back to the composite (answers), like the no-summary path.
Fixes Codex P2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): don't dispatch a fan-out prompt after the race already settled

If abort/timeout won while the attempt was still in setup, the detached attempt
later reached prompt() anyway — onPrompt only cancelled AFTER the backend query
started, so a timed-out/cancelled agent could still run a read-only prompt in the
background and, for a main-agent timeout, overlap the later summary on the same
backend. Check raceSettled() before dispatch and just tear down the late
sub-session instead. Fixes Codex P2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): strip fan-out serialization markers from conversation history

A fan-out turn's persisted message body is the composite — HTML-comment section
markers plus marker-escaped content. buildConversationHistoryBlock fed that raw
into <conversation_history>, so a later @agent turn saw hidden markers,
status/error attributes, and the escape sentinel that were never visible to the
user. Render the clean composite from the live/parsed fanout turn instead
(falling back to parsing the body); non-fan-out messages are unchanged. Fixes
Codex P2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* feat(agent-mode): adaptive two-mode fan-out summary (answer vs deliverable)

The agreements/disagreements form only suits convergent questions. When the
agents each produce an ALTERNATIVE artifact (a rewrite, draft, message, code,
plan), those are options to choose between, not claims to reconcile. The summary
prompt now first picks a mode: ANSWER mode keeps the each-agent / agreements /
disagreements reconciliation; DELIVERABLE mode instead lists what is DISTINCTIVE
about each option (angle, tone, structure, who it suits) and gives a
Recommendation (pick one, or which parts to merge), without reproducing the
artifacts. The summarizer detects the mode itself, so the user need not flag it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): require the full composite wrapper before parsing a fan-out body

parseFanoutComposite keyed on a mere marker substring, so a normal assistant
answer that just MENTIONS the format (e.g. <!--copilot:...--> in a code block)
parsed to a non-null empty turn; loadMessages then set message.fanout and reload
rendered the fan-out card instead of the real answer, hiding it. Require the
COMPLETE wrapper (a version-agnostic open marker AND the close marker) and reject
an empty parse (no summary text and no agent sections). Fixes Codex P2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): cap agent answers fed into the summary prompt

buildSummaryUserPrompt concatenated each succeeded answer in FULL, with no cap
(unlike the persisted and history paths), so one huge answer (a long file
excerpt or tool dump) could push the summary sub-session past its context window
or time it out, leaving an otherwise-useful turn with no summary. Truncate each
answer to FANOUT_SUMMARY_ANSWER_MAX_CHARS (tighter than the persisted cap, since
several answers stack into one model input) with a marker. Fixes Codex P2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): serialize live fan-out state on autosave (no blank bubble)

A fan-out turn streams into message.fanout; its composite body is written to
message.message only at completion. If autosave fired mid-turn (a long turn
outliving the debounce) and the user then reloaded/closed/crashed, formatChatContent
serialized only the empty message.message, saving a blank assistant bubble even
though per-agent text was visible. Serialize the live fanout (backend-id labels;
the completed turn later overwrites the body with display-name labels) so the
streamed answers survive an interrupted autosave. Fixes Codex P2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* docs(agent-mode): justify the multi-agent fan-out PR scope and design

Add designdocs/MULTI_AGENT_FANOUT_ARCHITECTURE.md: a full accounting of the
~7.2k-line PR (54% tests, fan-out core isolated in a new folder, additive
cross-cutting edits) plus the design rationale for routing, the composite
message object, backward-compatible persistence, isolation/timeouts/caps, the
adaptive summary prompt, and the two-layer paywall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): stop fan-out sub-sessions leaking into Recent Chats

Fan-out sub-sessions open a real newSession on the backend; opencode/codex
persist it to disk and the native-discovery sweep then lists each as a phantom
Recent Chats entry with an auto-generated title. proc.cancel ends the query but
does not delete the session, so the prior "never persisted" comment was wrong
for those backends. Tombstone each sub-session id on creation (via the existing
index tombstone honored by mergeDiscoveredSessions) so the sweep skips it, even
across restarts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* test(agent-mode): slim fan-out test suite to load-bearing cases

Cut the multi-agent fan-out test suite from ~3,872 to ~1,800 added lines: keep
one happy-path plus the load-bearing edges per module (serialize/parse +escape
round-trip, routing, both paywall layers, one orchestrator success/error/cancel/
summary-unavailable, persistence non-blank, one continuity replay, read-only
safety gating) and drop exhaustive timing/grace permutations and most pure-render
UI assertions. Full suite green (3,844).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* refactor(agent-mode): trim verbose comments on fan-out code to STYLE_GUIDE minimum

Comment-only pass over the new fan-out/paywall production code: condense or drop
multi-line JSDoc that restated the code and decision-ref narration, keeping the
non-obvious WHY notes (escape correctness, cancel/settle ordering, read-only
invariants, referential stability). Pure comment changes; no code/behavior
change. Prod added lines 3,326 -> 2,886. Suite green (3,844).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* refactor(agent-mode): drop tool/plan from fan-out history, trim history comments, slim doc

Phase 4 (behavior-preserving golf): the fan-out conversation-history block no
longer renders agent-internal tool-call/plan cards (all USER context kept: prose,
image markers, selections/notes/web-tabs). Trim the history-machinery comments to
the STYLE_GUIDE minimum and inline a single-use helper. Slim the architecture doc
to a concise reference. The orchestrator trailing-chunk grace is intentionally
left intact (it captures answer text some backends flush after prompt resolve).
Suite green (3,844).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* refactor(agent-mode): trim plusUtils multi-agent entitlement comments

Comment-only condensation of the multi-agent paywall helpers' JSDoc (left over
from the Phase 3 comment pass). No code or behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): close fan-out sub-session sweep race; restore sentinel test; drop dead helper

- Recent Chats leak: the index tombstone is async/fire-and-forget, so a native
  listSessions sweep racing the in-flight turn could still surface an ephemeral
  fan-out sub-session. Also skip currently-registered read-only sub-sessions in
  the sweep (closes the window before the tombstone lands).
- Restore the serializer collision test's PUA escape sentinel (was reduced to an
  empty string during the test slim, dropping coverage of the raw-sentinel path).
- Remove the now-dead collapseFanoutTurnToSummaryText helper and
  FANOUT_SUMMARY_UNAVAILABLE (composite persistence superseded them; their
  comments wrongly said "summary only").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): deny unknown MCP tools in read-only fan-out turns

An MCP tool whose name isn't a known built-in derives to kind `other`, which the
read-only gate otherwise allows — so a third-party mutating tool like
mcp__filesystem__write_file could run inside a read-only QA sub-session, breaking
the no-writes guarantee. Fail safe: deny unknown MCP tools (mcpServer present +
kind `other`) in read-only sessions; known-classified MCP reads still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* docs(agent-mode): document fan-out history contract + deferred design debt

Record the intentional history tradeoff (prose + user context, no tool/plan
cards) and the read-only MCP fail-safe, and capture the deferred design
follow-ups (host sub-session primitive, fanoutTypes split, explicit summary
status enum) so they aren't lost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): deny unknown ACP MCP tools in read-only; fingerprint live fan-out in autosave

- Read-only consistency: the ACP permission prompter (opencode/codex) allowed
  kind `other`, so an unknown/MCP tool could run in a read-only fan-out turn —
  the same hole already closed on the Claude SDK bridge. Deny `other` there too
  (read/search/fetch/think/switch_mode still pass).
- Autosave de-dupe keyed only on the last message's text, which stays empty for
  an in-flight fan-out turn, so mid-stream saves were skipped and a crash kept
  only the first partial snapshot. Fold a fingerprint of the live fan-out slots
  (per-answer status+length, summary status+length) into the signature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): anchor @-mentions to the active session, not a cold-starting backend

The fan-out mention anchor (mainAgentId) preferred getStartingBackendId() over
the active session's backend. While another backend cold-starts in a different
tab, startingBackendId is set before the new session becomes active, so a queued
prompt from the visible chat (e.g. Claude) that mentions the starting backend
(e.g. @opencode) saw mainAgentId === opencode — the degenerate single-answerer
case — and silently flushed back as a single-agent turn instead of fanning out.
Anchor to the active session's backend when one exists; fall back to the starting
backend only for the initial no-session startup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…2629)

Apply the shared running-gradient `copilot-shimmer-text` effect (the same one
used by BottomLoadingIndicator) to the fan-out turn's active status lines —
"Waiting for answers…", "Writing summary…", "Streaming…", "Thinking…" — via a
`shimmer` prop on FanoutStatusLine. Terminal/error lines (cancelled, unavailable,
failed) stay static.


Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…2630)

* fix(agent-mode): highlight the active multi-agent response tab

The selected fan-out tab only swapped its background fill, which didn't read as
active. Match AgentTabStrip's proven active-tab treatment: accent border + faint
accent tint + normal-weight medium text, so the active tab is clearly distinct
from the others without hovering. No !important — resolved via cn()/twMerge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* feat(agent-mode): make the multi-agent summary readable (headings, not dense bullets)

The summary used bold inline labels and one dense bullet per agent. Switch to a
FORMAT rule that leads with the bottom line, then proper ### subheadings (Each
agent / Agreements / Disagreements, or Options / Recommendation) with short
paragraphs and whitespace, and only sparse one-line bullets for genuine lists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): a finished-but-empty fan-out slot no longer shows check + "Thinking…"

A fan-out agent that finished with no answer text (status done, empty text)
rendered a green success check on its tab AND "Thinking…" in the body — reading
as both done and still working (repro: opencode). Resolve a done+empty slot to a
new `empty` presentational state: the tab dot is a muted slash (not a check) and
the body reads "This agent did not answer." instead of the running-spinner
fallback. Re-applies handling that was dropped during the earlier slim-down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ce (#2633)

* fix(agent-mode): hide Recent Chats entries not resumable on this device

A chat started on another machine syncs its markdown note (carrying the
backend session id) via the vault, but the backend's transcript store
(~/.claude/projects, opencode/codex session dirs) is machine-local and
does not sync. The row therefore appears in Recent Chats on a second
device but dead-ends on resume (Claude loops on "No conversation found").

Add an optional `sessionExistsLocally` capability to the BackendProcess
contract and implement it for the Claude SDK (a cheap fs check on the
session jsonl). `AgentSessionManager.getChatHistoryItems` now drops
markdown chats a running backend definitively reports absent; an unknown
answer (no capability, backend not running, or probe error) keeps the
row, so a local chat is never hidden.

Memoize the resolved Claude config dir so the per-entry locality probe
doesn't repeat a Plus license-key decryption on every history render.

Refs logancyang/obsidian-copilot-preview#173

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): tombstone native twin when dropping non-local chat

A normal autosaved chat carries both a markdown note and a flushIndexTouch
session-index entry on its origin machine. When the note syncs to a second
device but the backend's local transcript store does not, dropping only the
markdown row left index.getEntries() still returning the same (backendId,
sessionId), so mergeChatHistoryItems resurfaced it as a native-only row that
dead-ends in loadNativeSessionFromHistory with no markdown fallback.

dropNonLocalMarkdownEntries now tombstones the matching index entry (cancelling
any pending index touch, mirroring deleteChatHistory) whenever the locality
probe definitively reports the session absent, so the non-resumable chat is
fully removed rather than reappearing.

Refs logancyang/obsidian-copilot-preview#173

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(agent-mode): explicit per-agent Default model setting

Each toggled-on agent (opencode / claude / codex) now has an explicit
"Default model" (+ effort) picker in its settings section, sourced from
that agent's enabled model list. This default seeds every new session and
every fan-out answerer on that backend, and an already-open chat picks it
up on its next turn.

The chat model picker is now transient: a chat-side model switch mutates
only the active session and no longer writes the durable default. The
settings picker is the sole writer of `agentMode.backends.<id>.defaultModel`.

- AgentSessionManager.applySelection no longer persists the selection.
- runCrossBackendPick seeds the freshly-spawned session with the picked
  model directly (new createSession seedSelection arg) instead of
  persisting-then-reading the default.
- AgentSessionManager subscribes to default-model settings changes and
  re-applies them to live sessions via descriptor.applySelection (next
  turn; in-flight turns unaffected).
- Extracted resolveEffortOptions (shared by the chat picker and the new
  settings picker); changing the selected model resets effort to the new
  model's first valid option, since opencode effort is model-specific.

Closes logancyang/obsidian-copilot-preview#186

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): address Codex review on per-agent default model

Two correctness fixes from Codex review of the per-agent Default model
setting:

- Defer the live default re-apply for sessions still in their startup
  window. setModel/setConfigOption throw before backendSessionId is
  assigned, so an immediate apply was lost with only a logged warning;
  the new chat kept its old seed. Queue the apply off session.ready and
  re-read the latest default then, so the final value wins when several
  changes land before startup completes.
- Make the settings default-model snapshot reflect model-cache changes.
  The useSyncExternalStore snapshot was only the preload status, so the
  post-"ready" effort-catalog prefetch never triggered a rerender and the
  Default effort dropdown would not appear until an unrelated rerender.
  The snapshot is now a cache signature covering status, cached state,
  and the effort catalog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): address second Codex review round

Two more correctness fixes from Codex review of 61c9948, both regressions
introduced by this PR:

- Preserve a transient effort across a cross-backend pick. runCrossBackendPick
  now seeds the new session without persisting, but Claude's
  applyInitialSessionConfig replayed the persisted default effort, overwriting
  the user's drafted effort on startup. Pass the resolved seed selection
  through to applyInitialSessionConfig so the seed's effort wins over the
  persisted default; a plain new session (no transient seed) still replays the
  persisted default unchanged.
- Represent an unset default explicitly in the settings picker. With no stored
  defaultModel the picker showed the first enabled model as selected even
  though createSession uses the agent's native default, and an effort-only
  change would silently persist that model. Add an explicit "Agent default"
  option that maps to the cleared state; selecting it clears the default, and
  the effort row only appears for a concrete default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): keep a stale default visible so it can be cleared

Codex review of 2c234d4 (P2): when a user saved a default and later
disabled that model (or all enabled models), getEnabledModelEntries
returned empty and the settings control was hidden, even though new
chats and fan-out still read getDefaultSelection and would keep starting
on the now-disabled model with no way to clear it.

Hide the control only when there are no enabled models AND no stored
default. A stored default whose model is no longer enabled is rendered
as a "(disabled)" option so the user can see it and switch to "Agent
default" to clear it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): revert live sessions to native default when cleared

Codex review of 32c13fe (P2): when the user set Default model back to
"Agent default", persistDefaultSelection(..., null) made the new value
null, so onDefaultSelectionsChanged's `if (!after) continue` skipped
live sessions. An open chat stayed pinned to the old explicit model even
though the settings copy says open chats switch on the next turn.

Treat a cleared default as a change to the agent's native default:
resolve the catalog-declared native model (availableModels[0]) and apply
it to live sessions on that backend, so the next turn uses native instead
of the stale explicit model. With no probed catalog there's no native id
to target, so the session is left as-is. The change-detection guard now
also covers null->null (no-op) correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): seed cleared agent-default sessions to catalog native

When an explicit default is cleared, a warm/running subprocess (e.g.
opencode) keeps serving the model baked into its spawn-time config, so a
brand-new "Agent default" chat would silently inherit the stale model.
createSession now falls back to the catalog native default selection when
there's no transient seed and no stored default, confirming the new
session onto native. With no probed catalog there's no native id to
target, so the seed stays undefined and behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): serialize default re-applies and seed fan-out clears

Two follow-up edge cases on the agent-default flow:

- onDefaultSelectionsChanged fired the live-session re-apply fire-and-
  forget, so two rapid default changes could race their setModel/
  setConfigOption round-trips and leave the session on a stale model.
  Re-applies now serialize per session on a chain that re-reads the
  latest default at run time, so the final settings value always wins.
  This also subsumes the prior "starting session" deferral.

- Fan-out sub-sessions read the default via FanoutHost.getDefaultSelection
  and no-oped on a cleared default, so multi-agent answers kept inheriting
  the model baked into a warm/running subprocess. The host now mirrors
  createSession's catalog-native fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): show unset default effort instead of first option

A stored default with effort: null means "let the agent choose", but
some catalogs (e.g. Claude's low/medium/high) enumerate only concrete
values, so the effort select fell through to effortOptions[0] and showed
e.g. "low" as selected while the runtime treated null as unset. The
picker now prepends an explicit "Agent default" (null-valued) effort
option when the catalog lacks one, and binds the select to the unset
value when no effort is persisted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): reset effort to agent default on a model-only change

Changing only the Default model used to auto-persist the new model's
first concrete effort (e.g. "low"), silently running new chats and
fan-out at an effort the user never picked. Now that the unset state is
representable, a model-only change persists effort: null (agent default);
the user can pick a concrete effort explicitly. This also drops any stale
effort from the previous model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): apply seeded effort through the backend's own channel

A cross-backend pick seeds the new session with a drafted (model, effort)
and confirmSeededSelection used to apply it via a raw applyModelWireId.
For config-option opencode (>=1.15.13) effort is a separate thought_level
option, so packing base/effort into the model wire id started the session
at the model's default effort or failed, and opencode has no
applyInitialSessionConfig to split it post-startup.

confirmSeededSelection now dispatches through descriptor.applySelection,
which splits model and effort per backend. Because that path guards its
model switch on the current state, the optimistic baseModelId seed is
first dropped for config-option backends so the real switch still fires;
setModel-style backends keep the seed (no blink) since they always issue
the round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-mode): reset seeded effort to native when cleared over a stale value

Follow-up to routing the seed through applySelection: a config-option
opencode process can bake a concrete effort (e.g. model/high), so after
the user clears the default effort to agent default a fresh session
reports the same base but the stale concrete effort. applySelection skips
the model write (base matches) and returns for null effort, leaving the
chat on the stale value. confirmSeededSelection now re-writes the bare
model option in that case to reset effort to the model's native default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…#2632)

* feat(agent-settings): tab the Agents settings into per-backend panels

Convert the Agents settings tab from stacked vertical sections into a
sub-tab strip (OpenCode / Claude / Codex / Quick Chat) built on the
existing setting-tabs primitive. The global default-backend picker and
MCP servers panel stay above the strip. Within each backend panel the
default-model picker now sits above the model enable list, followed by
the backend's binary/auth config. Quick Chat keeps its existing model
curation. Placement-only revamp; no default-model resolution, seeding,
or persistence changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* fix(agent-settings): anchor tab strip on switch; add Quick Chat icon + default-model picker

Switching sub-tabs no longer jumps the settings scroll: the tab strip is
pinned to its pre-switch viewport position when panel heights differ. The
Quick Chat tab gains a chat-bubble icon (was iconless) and a Default model
picker mirroring the per-agent panels, writing the shared defaultModelKey.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

* chore(scripts): reload deploy-target vault by cwd; timestamp-only build label

The Obsidian CLI resolves its target vault from the working directory, so the
reload step now runs from $VAULT_PATH (was hitting the repo's own vault). The
manifest label is tagged with the build timestamp only, not the branch name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…#2635)

Reloaded Agent Mode messages render through ChatSingleMessage, which
builds `.message-segment` text divs but never adds `markdown-rendered`.
Without that class Obsidian's native reading-view stylesheet does not
apply, so inline `<code>` spans lose the gray background pill the live
render path (AgentMarkdownText) gives them.

Add `markdown-rendered` to each rendered text segment so reloaded
messages match the live styling, scoped to the segment divs to avoid
regressing the existing `.message-content` rules.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…#2636)

Multi-agent QA sub-sessions hard-denied the `execute` tool kind, which
silently killed web search for any backend whose only web path is a
shell-run skill script. opencode loads the `copilot-web-search` skill and
runs it via bash, so the denial left it with no research path and it
returned an empty answer ("This agent did not answer."), while Claude and
Codex answered via their native web search.

Narrow the read-only gate to deny only genuine vault mutation
(edit/delete/move); allow `execute` so Copilot's skill-script relay tools
(web search/fetch, PDF, YouTube, X) run. The read-only prompt preamble and
each backend's native read-only sandbox keep shell from writing the vault.
`other` (unverifiable third-party MCP) stays denied as before.

Rename `isWriteOrExecToolKind` -> `isVaultWriteToolKind` to match the new
semantics and update both enforcement sites (UI prompter + Claude SDK
bridge) and their tests.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(agent-mode): project-scoped Agent Mode workspaces (PR2)

Turn Agent Mode "projects" into Claude Projects-style workspaces: entering a
project gives a scoped workspace with its own sessions, chat history, and
materialized context, while the global workspace becomes one special scope
(GLOBAL_SCOPE sentinel) sharing the same code path.

Session scope
- AgentSessionManager stores sessions by internal id but binds each to an
  immutable projectId; per-scope MRU, resolveSessionCwd, getSessionsForScope, and
  enterProject/exitProject all key off that projectId, and the active-session
  invariant (active.projectId === activeProjectId) keeps existing UI helpers
  untouched.
- Backend restarts / in-place replacement inherit the replaced session's
  projectId; project scope reuses the warm process instead of adopting the
  vault-root probe session.
- Re-entering a project opens as a fresh visit: conversational chats detach from
  the tab strip (kept live in the pool, listed in chat history) instead of
  dragging every prior tab back, while an unused empty landing is reused rather
  than stacked. The global workspace keeps its restore-the-last-tab behavior.

Context materialization (off-vault shared cache)
- projectContextMaterializer + contextCacheStore + manifestBuilder capture a
  project's URL/YouTube/PDF context once per session (single-flight per project)
  into a per-vault, off-vault store shared across projects
  (~/.obsidian-copilot/vaults/<vaultId>/context-cache/): a source referenced by N
  projects is converted once per vault, not N times. Paths derive from one source
  of truth (conversionsLocation.ts); getVaultId lives in utils/appPaths.
- Layout: shared remotes/ + files/ snapshots keyed by source identity (md5),
  per-project markers/<md5(projectId)>/ failure markers. A root-confined node:fs
  backend (createNodeContextCacheFs) writes atomically (temp+rename), throws on
  write/mkdir/read while list/remove/clear tolerate a missing target (the store
  treats a failed read as a cache miss), and never ascends past the cache root; it
  loads node:fs lazily behind the desktop boundary so Obsidian mobile never pulls
  it into the bundle.
- A per-artifact async-mutex lock makes two projects cold-converting the same
  source converge to a single fetch (the waiter re-reads meta inside the lock and
  cheap-skips). Shared snapshots are never reconciled per-project; a project's
  stale marker is cleared on a cheap-skip hit or when a usable stale snapshot
  survives a failed refresh.
- Cache semantics match the legacy CAG cache: a successful snapshot is kept
  indefinitely (fingerprint cheap-skip, no TTL); a failure with no usable
  snapshot writes a marker and is cheap-skipped on later automatic runs
  (re-surfacing the error) until the file changes or the user retries. A
  schemaVersion stamped into every snapshot/marker makes a future format change
  re-materialize instead of misreading stale content; MATERIALIZED_SOURCE_TYPES
  is the single source of truth for source kinds.
- The first prompt inlines a <project_context> block listing absolute snapshot
  paths (keyed by type:source so a same-URL web+youtube pair resolves correctly)
  so all three backends reach the shared store; opencode gets an
  external_directory allow for the cache root. Per-source status mirrors CAG's
  ProjectLoadTracker into the projectId-keyed agentProjectContextLoadAtom
  (remotes parallel, files sequential; each flips Ready/Failed as it settles) and
  the composer shows a context status icon and queues sends while materializing.
  The never-reject contract and contextSignature/warm reactivity are preserved;
  released CAG caches (.copilot/*) are untouched.

Instructions mirror
- project.md stays the single source of truth, with a marker-gated AGENTS.md
  mirror generated by ensureAgentsMirror: codex/opencode auto-discover it from
  the session cwd, claude gets the same composed instructions via
  getProjectProfile. A built-in project policy is layered into each project's
  instructions (a user-authored, unmarked AGENTS.md is left untouched).

Landing & project UI
- AgentLandingStack spine with project landing state, header, Welcome card,
  Project Chats tab variant, and project CRUD (anchored create popover, edit
  modal, overflow menu); shelf lists cap at 5 behind a View-all popover.
- One context editor (ProjectContextSourceEditor) is shared by the home shelf and
  the Edit Project modal; the Manage modal leads with a Links section whose single
  add affordance is the sidebar "+URL" popover (the old right-pane inline URL
  input is removed for consistency with Tags/Folders/Files). A three-state
  (processing / ready / failed) status popover offers inline per-source retry, its
  landing refresh deferred to popover-close and scope-guarded. The web/youtube
  classification is consolidated into one UrlKind type reused across every URL
  surface. Truncated previews mark the cut with a labelled divider and a "Copy
  full content" pill, and cap render so large snapshots don't freeze the modal.

History scope
- Agent chat history is scope-keyed: a project view lists only its own chats,
  while GLOBAL_SCOPE is the flat all-chats view. Native session history is scoped
  to projects and resumed transcripts hydrate from the session's scope cwd; legacy
  chats with no projectId resolve to GLOBAL_SCOPE. A failed resume/create during a
  cross-scope history load rolls back the active scope (rollbackHistoryLoadScope)
  so the active-session invariant can't be left split.

Todo / plan
- Unified backend todo lists across claude/codex/opencode feed the project info
  popover, with per-session todo-id tracking and symmetric plan clear.

Hardening & fixes
- contextCacheFs rejects `..`, absolute, and root-escaping paths; symlink escape
  is documented as out of the cache's threat model.
- A hard-disabled composer (orphaned project) blocks keyboard sends and queued
  flushes, not just pointer events, so a turn can't drain into a dead project.
- The per-message attachment envelope is renamed <copilot-context> ->
  <attached_context> to disambiguate from the project-wide <project_context>.

Tests
- 30+ suites covering session scope, the off-vault cache store/fs (including a
  real-filesystem dedup integration test), the materializer, AGENTS.md mirror,
  todo-plan pipeline, landing UI, the context-signature refresh state machine, and
  the opencode external_directory allow; plus a mobile load-boundary smoke guard
  over the cache consumers.

* fix(agent-mode): keep project edit modal open on save failure

ProjectInfoPopover's edit onSave swallowed updateProject errors into a
Notice, so onSave resolved and AddProjectModal closed — discarding the
user's edits on duplicate-name / folder-collision failures. Log and
rethrow instead so the modal keeps the form open and surfaces the error,
matching AgentProjectRowActions.handleEdit.

* fix(agent-mode): preserve project context after first-turn fan-out; clear off-vault markers on delete

A first-turn multi-agent fan-out set firstPromptSent while only the
ephemeral sub-sessions received the <project_context> block — the visible
backend is never prompted on that path. The next normal turn then injected
null, permanently stripping the project manifest from the main chat. Stop
the fan-out branch from consuming the first-turn flag so the next visible
turn delivers it. Adds a regression test.

Also clear the off-vault failure-marker bucket (markers/<md5(projectId)>)
in deleteProject so a project recreated under the same id can't inherit
stale negative-cache state. Desktop-gated + dynamically imported; the
helper roots a confined fs at the bucket and reuses recursive clear().

* fix(agent-mode): refresh empty project landing on System Prompt edit

An empty landing session bakes in its project instructions at creation
(Claude via systemPromptAppend, codex/opencode via the AGENTS.md mirror),
but the landing-refresh trigger keyed on the materialization signature,
which deliberately omits systemPrompt. A System-Prompt-only edit therefore
left the open landing untouched, so the first message ran with stale
instructions until a manual new chat.

Add getProjectLandingCaptureSignature (materialization signature + the
instruction body) and key the landing-refresh observer on it, leaving
getProjectContextSignature untouched so prompt edits never trigger
re-materialization. Adds tests.

* fix(agent-mode): preserve compose draft typed during landing refresh

The empty-landing context refresh replaces the session in place; its
draftEmpty guard ran before the async backend-startup window, so text the
user typed during that window landed in the old session's draft and was
pruned when the old session closed — breaking the guard's stated 'never
discard text the user has started typing' contract.

Add useAgentInputDrafts.migrateDraft and call it right after the swap to
carry any draft typed during startup onto the new session id. Writes
setDrafts directly to bypass the live-id guard; ordering is safe because
closeSession awaits cancel() before deleting the old session, so the
source draft is still present when migration runs. Adds tests.

* fix(agent-mode): don't reuse a project landing that captured stale instructions

The previous fix made the active landing refresh on a System-Prompt edit,
but enterProject's session-reuse path gated only on the materialization
dirty flag, which deliberately ignores systemPrompt. So editing a
non-active project's System Prompt then re-entering reused the old landing
and ran the next turn with stale captured instructions.

Track a per-session landing-capture signature (materialization signature +
instruction body) at creation and require a reuse candidate to match the
live project config. When spawning fresh, detach existing empty landings so
a stale blank one can't linger. Leaves the materialization dirty machinery
untouched (it must stay systemPrompt-insensitive). Adds a regression test.

* docs(agent-mode): note PR2 tag/extension routing and Tier-1 exclusion scope

Two DESIGN NOTEs at the project context materializer, recording decisions
already settled in PR2_DESIGN.md so future reviews don't re-flag them:
- tag/extension inclusions are forwarded as source labels and reached via the
  agent's native search; precise resolution (MCP tag_search) is a PR3 item.
- exclusions are Tier 1 (best-effort: don't materialize / don't feed); they do
  not hard-block the agent's native grep — that needs a Tier 2/3 sandbox,
  deferred past PR2.

* fix(agent-mode): let project sessions read opted-in off-vault context

The built-in project prompt's default-only-cwd rule buried its carve-out in
a negative clause, so agents read an off-vault materialized snapshot path
(~/.obsidian-copilot/.../context-cache/...) as a forbidden external file and
declined to read it — even though the project itself configured that source.

Restructure the built-in prompt by axis (write / read+exception / boundary)
and name the <project_context> block explicitly: sources listed there are
opt-in to read and search even outside cwd, and a -> <abs path> snapshot
pointer is read directly. Pin the cross-file <project_context> tag coupling
with a regression test that diffs the prompt against the manifest's real
output.

* refactor(agent-mode): unify agent context status into one shared view

Per-item conversion status (icon + label + lookup key) was duplicated across
three agent surfaces: the composer status popover, the Manage modal's Links
panel, and — newly needed — its File Context file list. Each had its own
status->icon mapping, and the file list had none at all (it was wired to the
CAG load atom, which the agent pipeline never populates, so agent files showed
no status while URLs did).

Introduce processingItemStatusView as the single source of truth
(processingSourceKey / buildProcessingItemLookup / getProcessingStatusLabel /
ProcessingStatusIcon) and route all three surfaces through it. The Manage modal
now builds one agent status lookup (gated to the agent variant) shared by both
the Links panel and the file list, so file rows finally show conversion status
keyed by file:<path>. The legacy CAG status stays separate (different cache,
different semantics). Adds useAgentProcessingItems({ enabled }) so CAG/mobile
callers skip the off-vault read entirely.

* feat(agent-mode): show conversion status + snapshot preview for project files

The context editor's File Context list showed no conversion status or preview
for agent project files, while the Links panel showed both for URLs — an
asymmetry, since file snapshots also live in the off-vault cache. Wire file
rows to the same agent status pipeline: a ready file now shows its status icon
and a 'view parsed content' arrow that opens the off-vault snapshot via
openAgentCachedItemPreview (the same opener URLs use). Markdown has no snapshot
so it shows neither; the CAG path is unchanged.

Collapse the per-prop enableLinks branching at the row into one
getFileRowStatusProps resolver (agent vs CAG dispatched once). Also corrects
stale comments flagged in review (STATUS_COLOR is CAG-badge-only; the agent
hook still subscribes to its atom but the value is unused when disabled).

* refactor(agent-mode): neutralize reviewer-dialogue comments; share source builder

Drop the "if a future review flags this, point them here" reviewer-meta
sentences from this branch's own comments (keeping all why/invariant
rationale), leaving the pre-existing base-convention instances untouched.

Extract buildAgentProcessingSources() so useAgentProcessingItems and
useAgentPersistentFailureCount construct the AgentProcessingSource[] shape
through one shared pure helper instead of two copies that could drift; each
hook still passes its own candidate set (draft vs saved), so behavior is
unchanged.

* fix(agent-mode): restore the <copilot-context> attachment envelope

The per-message attachment envelope was renamed <copilot-context> →
<attached_context> in an earlier feature commit (8b880361), bundled as an
incidental "disambiguate from <project_context>" tweak. That changed AI prompt
content emitted to EVERY agent chat (not just project-scoped ones) without an
explicit request, against the repo rule "Never modify AI prompt content unless
the user explicitly asks"; base v4-preview has used <copilot-context> since
Agent Mode was introduced. Revert the tag (the inner instruction text was
unchanged) so the established prompt contract is preserved. The two
claudeSessionTranscript tests already expected <copilot-context>, so this also
restores prod/test consistency.

* fix(agent-mode): supersede a stale in-flight context materialization on edit

The per-project single-flight let any non-force caller join the in-flight run
unconditionally. If a project's context was edited while an earlier
materialization was still running (slow URL/PDF conversion + a queued first
prompt), the next session joined the stale run and received the pre-edit
<project_context> / additionalDirectories — the queued send could flush with
the old source set, and the new links/files were only captured on a later chat.

Carry the run's context signature in the in-flight entry and join only when it
matches the caller's current record signature; a differing signature supersedes
via the existing take-over-slot path (which defers disk work until the prior run
settles, so no cache-file race). Regression test added to the single-flight
describe.

* fix(agent-mode): roll back project scope when auto-spawn fails

enterProject optimistically parks the prior scope, points activeProjectId
at the new project, detaches its tabs, and nulls activeSessionId before
awaiting the auto-spawn of the scope's first session. When that spawn
rejects (e.g. a missing backend binary), the callers only surface a
Notice, leaving the manager scoped to a project with no active chat until
the user manually recovers.

Wrap the project-scope auto-spawn in spawnEnteredScopeOrRollback, which
restores the previous scope's activeProjectId + active-session pointer and
re-notifies on failure, then rethrows so the caller still reports it. The
rollback is guarded on still being parked in the attempted scope so a
concurrent enterProject isn't clobbered — mirroring the existing
rollbackHistoryLoadScope precedent for the history-load path.

* fix(agent-mode): address review findings (URL trim perf, snapshot meta parse, docs)

Three PR-introduced fixes from the PR2 review plus a deferral note:

- urlTagUtils: rewrite trimUrlTrailingPunctuation from O(n²) (a full split
  scan per stripped char) to O(n) — pre-count brackets once, then walk the end
  pointer left. A pathological trailing-bracket paste no longer freezes the main
  thread. Semantics are unchanged (balanced brackets kept, unbalanced stripped,
  hidden punctuation re-trimmed). Adds a 100k perf tripwire test.

- contextCacheStore: anchor parseSnapshotMeta's close marker to a line boundary
  (\n--> ) instead of the first --> anywhere, which a source path containing -->
  would match inside the embedded JSON, truncating it and forcing a permanent
  cache miss. Adds a regression test.

- projects/state: correct doc comments — project.md is the SSOT, AGENTS.md is
  its generated mirror (was inverted).

- AgentSessionManager: DESIGN NOTE only — detached sessions on project re-entry
  are kept alive intentionally and not yet evicted; bounding the idle class is
  deferred (needs a per-session backend close primitive).
#2643)

* fix(agent-mode): ship Windows cmd/ps1 runtime for builtin relay skills

The builtin Copilot Plus relay skills (read-pdf, web-search, web-fetch,
youtube-transcript, fetch-x) shipped a POSIX `.sh` plus a Node `.mjs`
fallback. On Windows a managed-opencode session launches in PowerShell
with neither `sh` (no Git Bash) nor `node` on PATH (Obsidian's reduced
PATH excludes nvm/Volta/Homebrew node), so the skill dead-ended — the
PDF report in #2634 hit exactly this ("node is not recognized").

Replace the Node fallback with a native Windows path, mirroring the
`miyo-search` model: each skill now ships one runnable script per OS — a
`.sh` for macOS/Linux and a `.cmd` wrapper that drives an adjacent
PowerShell `.ps1`. Windows PowerShell 5.1 ships with the OS, so the
relay (TLS1.2, base64 + HTTP POST, same env-driven config, same
no-license / 401-403 fallback mapping) runs with zero install. SKILL.md
"How to run" and the system-prompt steering now route per OS with no
"install Node.js" dead-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(agent-mode): send PowerShell relay body as UTF-8 bytes

Windows PowerShell 5.1 ASCII-encodes a string -Body by default (UTF-8
only became the default in 7.4), so non-ASCII queries/URLs would arrive
corrupted at the relay. Encode the JSON as UTF-8 bytes and pass the
byte[] verbatim (matching curl), with charset=utf-8 on the content type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(agent-mode): emit Windows relay output as UTF-8

Windows PowerShell 5.1 defaults Console.OutputEncoding to the system
code page, so non-ASCII relay output (Japanese results, fetched pages,
transcripts) could be mojibaked before the agent read it. Set
[Console]::OutputEncoding and $OutputEncoding to UTF-8 at the top of the
script, the output-side counterpart to the UTF-8 request body fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(models): guard image sends to non-vision chat models

Add a shared modelSupportsVision() helper and block sends in
Chat.handleSendMessage when images are attached but the active chat
model is known to lack vision — Notice names the model and preserves
the typed text + images instead of failing silently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent-mode): guard image sends to non-vision agent models

Enrich agent picker entries with capabilities derived from the
models.dev catalog (by provider + baseModelId), and apply the same
known-non-vision guard in AgentChatInput. Models we can't resolve in
the catalog keep capabilities undefined and are never falsely blocked.
Catalog access is threaded as a structural type to respect the
agent-mode ui import boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(byok): show model capabilities + add vision/reasoning overrides

Render read-only capability icons per model row in the Configure
provider dialog, and add a collapsible Advanced panel with per-model
vision/reasoning toggles. Overrides overlay onto ConfiguredModel.info
modalities/reasoning at save, surviving the catalog-first resolve
precedence (R1) and flowing through the existing bridge to become
CustomModel.capabilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(models): show modality icons in BYOK + agent pickers; hide reasoning icon

BYOK checklist and the agent-mode model picker derived capabilities from the
async models.dev catalog but never loaded/reacted to it, so no icons rendered
(legacy works only via hardcoded BUILTIN_CHAT_MODELS.capabilities). Warm the
catalog and re-render on change in both surfaces.

Hide the reasoning/"thinking" capability icon everywhere (ubiquitous on modern
frontier models) while keeping ModelCapability.REASONING in the data model so
runtime extended-thinking gating (chatModelManager, chain runners, Bedrock)
stays intact.

- CatalogLookup: optional ensureLoaded()/onChange(); useAgentModelPicker warms
  + re-renders on catalog populate
- ConfigureProviderBody: ensureLoaded + onChange bumps catalogMetadata memo;
  drop the Reasoning override toggle (keep Vision)
- model-display: remove reasoning icon + "Reasoning" text label

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-models): show vision icon in agent pickers from persisted info

The settings agent model picker (ModelEnableList) rendered no capability
icons at all, and the chat agent picker derived them only from a live,
often-cold models.dev catalog lookup keyed by reported provider — so the
vision icon never showed reliably for agent models.

Source vision from each model's persisted ConfiguredModel.info.modalities
(the snapshot taken at setup, like Copilot Plus's explicit image input) —
the same single source of truth BYOK and legacy already use, no live
catalog required:

- EnabledModelEntry gains optional `capabilities`; the claude/codex and
  opencode enabled-entry builders populate it via capabilitiesFromConfiguredInfo
  (undefined when info has no modalities → picker still falls back to catalog)
- chat agent picker prefers enabled.capabilities over the catalog lookup
- ModelEnableList renders ModelCapabilityIcons; toRow derives them from info,
  so the settings agent + Quick Chat lists finally show the vision icon

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* update modality indicator

* refactor(byok): remove manual vision capability override

The "Advanced" section in the Configure Provider dialog let users
manually toggle a model's Vision capability — unintuitive, since
vision is already detected automatically from the models.dev catalog
(or the persisted snapshot). Remove the override entirely.

- Drop the Advanced collapsible + its override state/plumbing
  (capOverrides, applyCapsToModelInfo, CapFlags) and retire the
  Risk R1 override-preservation logic; capabilities now ride on each
  ModelInfo via resolveModelInfo.
- Fix the chat image-block notice that pointed at the now-removed
  "enable Vision in provider settings" path.

Automatic detection (modality icons, image-send guards) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(agent-picker): drop live-catalog fallback; use persisted capabilities only

The "snapshot at save time" architecture already exists — every
ConfiguredModel.info carries modalities + reasoning, and all three agent
backends feed the picker capabilities from that persisted snapshot. The live
models.dev catalog in the picker was a redundant fallback, so this removes it
entirely along with the complex useCatalogVersion hook.

- agentModelPickerHelpers: delete CatalogLookup/CatalogModelInfo,
  capabilitiesFromModelInfo/lookupCatalogModelInfo/capabilitiesFromCatalog;
  drop the catalog param throughout; capabilities = enabled.capabilities.
- useAgentModelPicker: delete useCatalogVersion; drop catalog arg + memo dep.
- AgentHome: useAgentModelPicker(manager) — no longer threads catalogService.
- tests: replace catalog-enrichment specs with persisted-capability propagation.

ConfigureProviderDialog/ByokPanel keep the live catalog — they show
capabilities for not-yet-saved models, which have no persisted snapshot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(byok): show modality icon in BYOK settings model table

Render the shared capability icon inline after each model name in the
read-only BYOK table, reusing capabilitiesFromConfiguredInfo + the same
ModelCapabilityIcons treatment the Configure dialog already uses. Brings
the last model surface to parity: a muted eye-off marks models that can't
take image input (incl. text-only embeddings), vision-capable and
unknown-modality models stay silent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(model-display): drop web-search globe badge; eye-off is the only modality signal

The no-vision eye-off is now the sole capability icon: web-search is no
longer badged with a globe, and getModelDisplayWithIcons stops appending a
"Websearch" suffix. hasCapabilityIcons/ModelCapabilityIcons reduce to a
single 'known to lack vision' check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat-picker): treat unknown model capabilities as unknown, not text-only

useChatModelPicker hand-rolled its capabilities array, always emitting an
empty (but defined) array even when ConfiguredModel.info.modalities was
absent. The Chat.tsx image guard reads a defined array as "known", so
legacy/manual/unknown snapshots got the eye-off icon and had images blocked.
Use capabilitiesFromConfiguredInfo() so capabilities stay undefined when
modalities are missing — matching the agent pickers and the intended
"unknown stays unblocked" semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2645)

* feat(model-management): add DeepInfra Copilot Plus models, default off

Expand the curated Copilot Plus lineup (`COPILOT_PLUS_MODELS`) from just
`copilot-plus-flash` to the full public set served by
models.brevilabs.com/v1/models: kimi-k2.6, glm-5.2, kimi-k2.7-code,
deepseek-v4-pro, mimo-v2.5, minimax-m2.7. These surface in the chat +
opencode pickers (the "Copilot Plus" section under OpenCode) for the user
to toggle on.

Only copilot-plus-flash is enabled by default. Adds an optional
`autoEnrollModelIds` to `registerPlusProvider`/`#reconcileModels` so Plus
can curate a default-on subset: newly-added models outside the set are
created (and shown in the pickers) but left unenrolled. `undefined`
preserves the prior enroll-everything behavior, so BYOK is unaffected.
This also keeps existing users' curation intact - the 6 new models arrive
off on the next sign-in sync rather than auto-enabling. Adds the new wire
ids to the `ChatModels` enum and threads canonical one-line descriptions
through from the models service.

Also fixes capability resolution for bridged Plus models so image input
routes correctly: CopilotPlusChainRunner.isMultimodalModel() ->
hasCapability() -> ChatModelManager.findModelByName() only searched legacy
`settings.activeModels`, so bridged ConfiguredModel-only models (e.g.
kimi-k2.7-code) were treated as non-multimodal and silently dropped
attached images. findModelByName now falls back to the active bridged
model when its name matches; that CustomModel carries capabilities derived
from its modalities (configuredModelToCustomModel maps image input ->
VISION). Legacy lookups are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

* feat(copilot-plus): reasoning-effort picker for Plus models + usage-cap credits prompt

Builds on the Plus model lineup to surface reasoning effort and handle cap hits.

- Mark the reasoning-capable Plus models (all but Azure kimi-k2.6) with
  reasoning: true so the chat + agent pickers offer an effort selector instead
  of "na". Matches the models service's supports_reasoning. These models don't
  reason unless an effort is picked, so flash stays fast by default.
- Forward the picked effort in the Simple Chat COPILOT_PLUS provider branch
  (the relay reads reasoning_effort), gated on the REASONING capability.
- Inject reasoning: true into the OpenCode provider model config so opencode
  surfaces a thought-level option for these models (it has no catalog entry for
  Copilot Plus / self-hosted OpenAI-compatible providers otherwise).
- Usage-cap (plan limit) errors now render a friendly, actionable message with
  a link to the website usage dashboard to purchase credits, instead of the raw
  relay error. New formatUsageCapError util (deep-searches the thrown error for
  the cap signal, cycle-safe) wired into the central getApiErrorMessage, so both
  Simple Chat and Agent Mode show it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

* fix(copilot-plus): re-sync capability fields so reasoning effort surfaces

registerPlusProvider's reconcile only refreshed displayName/description on
existing ConfiguredModels, never the capability fields. So an existing Plus
user kept a stale snapshot with reasoning unset — the chat bridge derived no
REASONING capability and the OpenCode config injection (gated on info.reasoning)
never fired, so the effort picker stayed empty ("na") even after the models were
flagged reasoning: true.

Reconcile now also re-syncs modalities, reasoning, and toolCall in place when
they drift (Plus models are server-curated, so there are no user overrides to
clobber). Verified end to end: persisted info.reasoning flips to true on
re-sign-in, so the reasoning models advertise effort.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

* fix(copilot-plus): keep flash fast by default; prefer bridged model capabilities

Addresses two Codex review comments on the reasoning-effort wiring:

- Gate enableReasoning on an explicit effort for the Copilot Plus provider.
  ChatOpenRouter.invocationParams falls back to `reasoning: { max_tokens: 1024 }`
  whenever reasoning is enabled without an effort, so flagging copilot-plus-flash
  REASONING-capable made the default-on model start spending reasoning budget.
  Now enableReasoning requires a user-picked effort, so flash stays fast until
  the user chooses one.

- findModelByName prefers the active bridged model on an exact name match before
  the legacy activeModels lookup. copilot-plus-flash exists in both (the built-in
  legacy entry advertises only VISION); the early legacy return masked the bridged
  REASONING/VISION capabilities, so CopilotPlusChainRunner.hasCapability treated
  flash as non-reasoning and hid reasoning content. The bridged model is the one
  actually running, so it wins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

* fix(copilot-plus): show usage-cap credits message on the streaming error path

getApiErrorMessage (with formatUsageCapError) was only reached from the planning
catch. The common cap path — the relay returns token_limit_error mid-invocation
after planning succeeds — goes through BaseChainRunner.handleError, which rendered
the raw 429 payload. Route usage-cap errors through formatUsageCapError there too,
so the purchase-credits message + dashboard link shows on the main invocation path
across all chain runners.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

* fix(copilot-plus): render usage-cap link as plain text for ErrorBlock

The streaming error path renders the message via ErrorBlock as plain text
(whitespace-pre-wrap), so the Markdown link syntax showed literally as
`[purchase credits ...](url)`. Switch formatUsageCapError to a plain-text
message with a bare URL, which is readable on the streaming path and still
auto-links in any Markdown-rendered context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

* Refine GLM-5.2 description in Copilot Plus model list

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

* Badge Copilot Plus group with privacy + license hint, taller model list

Add a "privacy" badge and a "Copilot license required" hover hint (key
icon) to the Copilot Plus group header in the model enable lists, and
raise the list's max height so more models are visible without scrolling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng prose (#166) (#2616)

* docs(plan): fix copying all text parts of an agent response (#166)

Otacon-approved plan for issue logancyang/obsidian-copilot-preview#166:
agentResponseText collects every text part, not just the trailing run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): copy/insert the full agent response, not just trailing prose (#166)

Copy and Insert/Replace acted on finalAnswerText, which reused
splitTrailingText and kept only the last contiguous run of text parts —
so a 'text -> thought -> text' turn dropped the earlier prose. Rename the
helper to agentResponseText and have it collect every non-empty text part
in stream order (joined with blank lines, then cleanMessageForCopy).
splitTrailingText is untouched, so the 'Worked for X' folding is unchanged.

Fixes logancyang/obsidian-copilot-preview#166

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(plan): archive completed plan for #166

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* update docs

* test(agent-mode): clarify interleaved-prose fixture wording (#166)

The flipped test reused fixture strings that said the early prose 'should
NOT be copied', which now contradicts the assertion (it is copied). Reword
the strings to describe the new behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nt token (#2644)

* feat(entitlement): gate multi-agent to Plus tier via signed entitlement token

Multi-agent fan-out was gated on the binary isPlusUser flag, which any valid
license flips true, so a future Lite tier would wrongly pass. Introduce a
server-signed entitlement token (ES256, verified offline via WebCrypto) as the
single entitlement primitive.

- Rename the broad flag isPlusUser to isPaidUser (any paid license, incl. Lite);
  all existing Plus-feature gates move to it, preserving current behavior.
- Add a new strict isPlusUser (tier >= Plus) derived from the token's
  multi_agent feature. The multi-agent UI gate and the authoritative
  send-boundary check (ensureMultiAgentEntitlement) now key on it.
- No-token fallback (isPlusUser = isPaidUser) preserves today's behavior until
  the server ships tokens alongside Lite/Pro, so there is no plan-name list in
  the public client to patch out.
- Settings sanitize migration backfills isPaidUser from the legacy isPlusUser.
- src/entitlement/ verifies the JWS signature with an embedded public key (no
  key can mint tokens client-side); forged data.json / faked responses fail.

Tests cover tier gating across Free/Lite/Plus/Pro plus the crypto layer
(valid, expired, wrong-user, tampered, unknown-kid, malformed).

Self-host migration to the token is deferred to logancyang/obsidian-copilot-preview#201.
Closes logancyang/obsidian-copilot-preview#200.
Design: "Copilot Entitlement Token Design".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(test): swap in Node WebCrypto when jsdom's subtle lacks generateKey

CI (Node 22) ships a jsdom whose window.crypto.subtle exists but is partial
(no generateKey), so the previous `!subtle` guard skipped the polyfill and the
entitlement crypto tests failed. Probe for the actual method and install Node's
complete WebCrypto when it's missing. Runtime is unaffected (test-only setup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(entitlement): never downgrade on an unverifiable token (Codex P1/P2)

Addresses two Codex findings:

- P1: when /license starts returning a token but the client's public keys are
  not shipped yet (or during kid rotation), every token verified as null and
  applyEntitlement cleared the flags, dropping paying users to free. Now
  applyEntitlement never clears: it returns false when it cannot verify, and
  validateLicenseKey falls back to turnOnPaid() for a license the server already
  confirmed valid. Only an authoritative negative (invalid license / no key)
  downgrades, via turnOffPaid.
- P2: removed refreshEntitlementFromCache() and its concurrent startup call,
  which could clear flags after checkIsPaidUser() restored them. The persisted
  flags already hold the last verdict and the online check is authoritative;
  offline expiry enforcement belongs to the deferred self-host migration (#201).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(test): run entitlement crypto test in node env, not flaky jsdom subtle

jsdom ships only a partial SubtleCrypto (no generateKey), and patching it in
jest.setup was nondeterministic across CI Node builds (passed on one commit,
failed on the next with no setup change). Run verify.test under the node
environment, where crypto.subtle is Node's complete WebCrypto — the verification
logic is WebCrypto-spec behavior, identical between Node and the webview.

Also drop verify.ts's @/logger import so the entitlement module is a pure leaf
(no settings/obsidian graph), which keeps the node-env test import-safe and
matches the module's intended dependency-light design. jest.setup now guards its
window usage so it is a no-op under the node environment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(test): force WebCrypto onto globalThis, not just window

The prior fix patched window.crypto, but a bare `crypto` reference in a module
resolves to globalThis.crypto, and jest's jsdom environment installs a partial
SubtleCrypto (no generateKey) on globalThis that the window-only patch missed —
so CI kept failing in jsdom while local (clean jsdom) passed. Patch globalThis
(and window) with Node's complete WebCrypto, with a fallback to replacing
`.subtle` if `crypto` is a locked accessor. Reverts the node-env docblock on the
crypto test; the global patch covers the standard jsdom env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(test): inject Node WebCrypto into entitlement verify instead of patching globals

Root cause of the repeated CI failures: jest's jsdom environment exposes a
partial, locked SubtleCrypto (no generateKey/ECDSA) on the global the module
resolves `crypto` from, and neither replacing window/globalThis.crypto nor the
@jest-environment node docblock reliably overrode it on CI (local jsdom resolves
clean, so it couldn't be reproduced locally).

Fix: give verifyEntitlement an injectable `subtle` option (default
`crypto.subtle`, as used at runtime on desktop + mobile), and have the test pass
Node's webcrypto.subtle explicitly. The test no longer depends on the
environment's global WebCrypto at all, so it's deterministic in any jest env.
jest.setup reverted to its original form (no crypto hacks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(entitlement): close 3 Codex findings (Lite bypass, log leak, offline expiry)

- P1 (Lite bypass): an unverifiable entitlement token no longer grants the strict
  gate. validateLicenseKey now calls markPaidPendingEntitlement() — paid so general
  Plus features work, but isPlusUser stays false so an unverifiable Lite token
  can't pass the multi-agent gate before the verifying key ships (fails closed).
  Tokenless (old server) still grants paid + Plus.
- P2 (log leak): parseBrevilabsResponse redacts the `entitlement` JWS before
  logInfo, so it never lands in the shared copilot-log.md.
- P2 (offline expiry): persist the token's exp as entitlementExpiresAt and have
  the strict gate (isPlusEnabled / useIsPlusUser) honor it, so multi-agent locks
  at expiry even while /license is unavailable. The broad paid gate keeps legacy
  behavior. ensureMultiAgentEntitlement's slow path re-reads it, so an expired
  token offline is blocked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* feat(entitlement): embed prod ES256 public key (kid ent-2026-06)

Populate ENTITLEMENT_PUBLIC_KEYS with the production verify-only public
JWK so verifyEntitlement can validate server-signed tokens offline. The
matching private key signs tokens in brevilabs-api.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

* fix(entitlement): require in-session signature proof for strict Plus

Address Codex P1: isPlusEnabled() trusted the persisted isPlusUser
boolean + entitlementExpiresAt without re-verifying the JWS, so an
edited data.json (isPlusUser: true + future expiry) bypassed the
multi-agent gate offline and in the startup window. The signed token was
not actually the gate on cached state.

Gate token-derived strict Plus on an in-memory, non-persisted proof that
a signed entitlement granting multi_agent was cryptographically verified
in THIS process — set by applyEntitlement (server response) or the new
verifyCachedEntitlement (cached token re-checked at startup, offline via
WebCrypto). A persisted boolean alone never sets it, so it fails closed.

The tokenless fallback (pre-token server, entitlementExpiresAt === 0) is
unchanged: no signed artifact exists to verify, so it honors the
license-confirmed flag as today and preserves new-plugin/old-server
compatibility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T4qVU9iszFGZ1q5ro8m2X

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tapping the key-icon hint (which opens the tooltip on mobile) bubbled to
the CollapsibleTrigger and collapsed/expanded the whole group. Wrap it in
a span that stops pointer/click propagation.


Claude-Session: https://claude.ai/code/session_018ou7V5v4UYHkzP3csodLLm

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The switch mixed CSS units: the track width used Tailwind's default
`w-10` (2.5rem, font-relative) while height, thumb, and travel were
fixed px via Obsidian's --size-4-* tokens. Obsidian's interface
font-size setting drives the rem root, so changing it stretched the
width alone and the thumb stopped landing at the edge (the reported
break).

Source all geometry from Obsidian's native small-toggle variables
(--toggle-s-width/-thumb-width/-thumb-height/-border-width,
--toggle-radius, --toggle-thumb-radius) so the switch is 100% px and
pixel-matches Obsidian's own toggles; the thumb travel is derived from
those sizes rather than a hardcoded distance. Public API, ARIA, and
keyboard behavior unchanged. Adds a React Testing Library behavior test.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…#2652)

* fix(agent-mode): always render the completed trail inline

Remove the "Worked for X" collapse that folded everything before the
final text block once a turn cleanly ended. It hid content the user was
mid-read on and broke reading flow; a finished turn now renders exactly
like the live streaming view — nothing is hidden. Also drops the
now-unused splitTrailingText helper and adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(agent-mode): drop dead turnDurationMs plumbing

With the "Worked for X" collapse removed, the turn's wall-clock duration
is never read. Remove turnDurationMs end-to-end (session, store, and the
persisted message type) and the turnStartedAt bookkeeping that existed
solely to compute it. turnStopReason is fully preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…on card (#2653)

* feat(agent-mode): add custom "Other" response to AskUserQuestion card

Each question in the inline AskUserQuestion card now offers an "Other" row (radio for single-select, checkbox for multi-select) that reveals a free-form textarea, mirroring Claude Code CLI. The typed text becomes the answer (single-select) or is appended to the checked labels (multi-select). Confined to the UI component — the answer stays a plain string, so no type/bridge/backend changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(agent-mode): cover custom "Other" response in AskUserQuestion card

Five RTL cases: single-select Other→trimmed text, multi-select presets+Other→joined, empty Other disables Submit, Cancel→{}, and a single-select preset regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…omposer accessory column, label context-held queue rows (#2662)

* feat(agent-mode): collapse empty context row, dock status icon in a composer accessory column, label context-held queue rows (#205)

* fix(agent-mode): omit showIndexingCard instead of passing NOOP so the indexing chip's render guard holds

* docs(agent-mode): state the overlay rejection rationale instead of build history in the accessory DESIGN NOTE
…se into overflow (#206) (#2660)

AgentTabStrip drove computeVisibleCount() from stripWidth state that only a
ResizeObserver callback updated, with no synchronous fallback measurement. On a
real remount, a missed or delayed first RO delivery left stripWidth stuck at 0,
collapsing project tabs into the "..." overflow menu despite available width.
Confirmed via an A/B repro (stub ResizeObserver + force remount -> 3 tabs
collapse to 1 at identical width).

Switch the effect to useLayoutEffect with a synchronous primer measurement,
guard for ResizeObserver-less test envs, and depend on sessionCount so the
0 -> N mount path (the strip returns null at 0, so the ref is absent) measures
once mounted. Mirrors the sync-primer pattern already used in PatternListEditor,
ProjectContextBadgeList, and ProjectContextSourceEditor.
* feat(agent-home): Relevant Notes tab body + pop-out hint + device-local prefs

Phase 1 of surfacing Relevant Notes on the agent home shelf. Adds
RelevantNotesShelfPanel (reuses the existing RelevantNotes component
with a dismissible 'open in its own pane' hint) and homeShelfPrefs
localStorage helpers (selected tab + hint-dismissed, device-local).
Not wired into the shelf yet — that is Phase 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-home): wire Relevant Notes shelf tab with persistence + pane mutual-exclusion

Phase 2. Adds the Relevant Notes tab to the agent home shelf (order:
Recent Chats, Relevant Notes, Projects), rendered unconditionally so
embeddings-off users hit the existing enable-semantic-search CTA.

- useRelevantNotesPaneOpen: hide the tab while the dedicated pane is
  open (no duplicate surface); restores when the pane closes.
- AgentHomeShelf: optional storageKey persists the selected tab in
  device-local localStorage; keeps the fallback-to-first-selectable
  resolution so an absent persisted id never breaks.
- AgentHomeTab/Section: count is optional; badge renders only when > 0
  (Relevant Notes omits it, no eager semantic compute).

Adds focused AgentHomeShelf tests (count suppression, persistence,
fallback). No ribbon; the dedicated pane + command stay as the pop-out
target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(agent-home): refresh Relevant Notes shelf tab on note-switch

The Relevant Notes shelf tab (inside CopilotAgentView) never refreshed on
note-switch and stayed empty for indexed notes: its useActiveFile listens
for ACTIVE_LEAF_CHANGE on the view's own eventTarget, but nothing fed that
target. The legacy CopilotView was fed by a special-case dispatch in main.ts
and RelevantNotesView self-bridged, but CopilotAgentView did neither, so its
useActiveFile froze at the mount seed.

Extract one shared registerActiveLeafChangeBridge helper and call it in
CopilotView, CopilotAgentView, and RelevantNotesView onOpen; remove the
legacy-only dispatch from main.ts so every chat view self-owns its bridge.

Verified via Obsidian CLI: the agent view's eventTarget now fires on
note-switch (0x before, 3x after).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(jest): map @orama/orama to its CJS build

configuredModelGrouping.test.ts began failing to load: it imports the
@/agentMode barrel (for ModelEnableRow/isOpencodeZenWireId), and this PR
made AgentHome — re-exported through that barrel via CopilotAgentView —
depend on RelevantNotes → findRelevantNotes → dbOperations, which imports
@orama/orama. Under jsdom, @orama/orama resolves to its ESM "browser"
entry, which Jest can't parse ("Unexpected token 'export'").

Map it to the CJS build it ships under dist/commonjs/, mirroring the
existing yaml entry. Only modules that already reach @orama/orama are
affected, so no passing suite changes behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
)

* feat(agent-mode): capture per-session token usage (context meter data)

Normalize what each coding-agent backend already reports into one
backend-agnostic SessionUsage, land it on AgentSession, expose it via
getSessionUsage(), and persist the latest snapshot in chat frontmatter so a
resumed session shows it immediately. No UI yet (Phase 2).

- session/types: SessionUsage + usage_update SessionUpdate variant
- sdk translator: emit usage from the Claude SDK result (used = input +
  cache_read + cache_creation + output; window = max modelUsage.contextWindow)
- acp/wireTranslate: map ACP usage_update (size/used/cost) to the domain
- acp/AcpBackendProcess: used-only prompt-result fallback, gated so it never
  clobbers a live occupancy figure (ACP totalTokens is cumulative, not in-context)
- AgentSession: store/notify usage, precedence carries a known window forward,
  seed on load; AgentSessionManager threads persistence load/save
- AgentChatPersistenceManager: round-trip usage as frontmatter JSON

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-mode): context-window meter in the composer

A circular meter beside the composer's context glyph, filled to the % of the
agent session's context window used; accent normally, warning color past 85%.
Click opens a popover with used/total tokens, an input·output·cache breakdown,
and estimated session cost. Falls back to the legacy count-only chip when a
backend reports usage but no window, and renders nothing before the first turn.

- useSessionUsage: subscribe to the backend's live SessionUsage
- AgentContextMeter: SVG ring + popover + TokenCounter fallback; owns its
  leading separator so it hides cleanly; guards non-finite values
- thread an optional usageIndicator slot through ChatInput → ContextControl →
  ChatContextMenu (pure pass-through; legacy simple chat unaffected)
- AgentHome mounts the meter for all agent sessions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(agent-mode): move context meter into the New Chat / History control row

Relocate the meter from the composer's context bar into AgentChatControls,
left of the New Chat button — the agent-mode analog of where the legacy
TokenCounter sat, matching the original intent. The ring trigger is now a
plain icon button (no inline % text; the percentage stays in the popover).

As a result the meter no longer needs the shared composer plumbing: the
optional usageIndicator slot threaded through ChatInput → ContextControl →
ChatContextMenu is fully removed, so those shared components are byte-identical
to before this feature and the legacy simple chat is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-mode): horizontal context-window bar in the meter popover

Revamp the popover to a cleaner, less technical layout: a "Context window"
label with `used / total (percent)` and the estimated cost on the same line
(cost at the right edge), above a horizontal progress bar (reusing the shared
Progress primitive). Tokens now format with one decimal + k/M (e.g.
248.0k / 1.0M). Drops the input/output/cache breakdown. The compact ring stays
as the control-row trigger; the warning color still shows on the ring and the
percentage past 85%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(agent-mode): keep the context-window label on one line

Use the smaller ui font token for the popover header and widen it (w-72 → w-80)
with a non-wrapping label, so "Context window" no longer breaks onto two lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-mode): open the context meter popover on hover

Drive the popover's open state from hover/focus instead of click, with a short
close delay and content-hover retention so moving onto the card doesn't dismiss
it. Auto-focus is suppressed so opening on hover never steals focus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(agent-mode): drive context meter from occupancy, not cumulative usage

The Claude SDK result message's `usage` sums every API call in a turn (tool
loops re-read the whole context from cache each iteration), so dividing it
by the window pegged the meter after tool-heavy turns even when the live
context still fit. Source occupancy from the last top-level assistant
message's own per-call usage instead, and pair it with that model's window —
prefix-matching the bare model id ("claude-opus-4-8") against the suffixed
`modelUsage` key ("claude-opus-4-8[1m]"), never `Math.max` across models.

Also: a windowless snapshot (ACP's cumulative prompt-result fallback) no
longer borrows a prior window to render a bogus percentage ring; it stays
count-only until a live occupancy update supersedes it.

Verified against runtime frame logs: a num_turns:10 Claude result summed
~646k tokens while real per-call occupancy was ~108k; opencode/codex report
occupancy directly via usage_update (this path was already correct).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(agent-mode): use Tooltip for the context ring, drop manual popover timer

The ring meter opened its stats card via a Popover with a hand-rolled hover
state and a setTimeout close delay (to bridge trigger→content). Replace it
with the shared Radix Tooltip, which handles hover/focus open-close and
hoverable content natively — removing the open state, close timer, and the
onMouse/onFocus/onBlur handlers. Relies on the TooltipProvider already at the
chat-view root, alongside the sibling control buttons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-mode): drop session cost from the context meter, show usage only

Remove `costUsd` from `SessionUsage` and everywhere that fed it — the Claude
SDK `total_cost_usd` mapping, the ACP `usage_update` `cost.amount` mapping, and
the meter's cost label + `formatUsd`. The meter now focuses solely on context
occupancy (used / window and the % ring). This also moots the ACP cost-currency
concern (raised in review), since no cost is surfaced at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The Report an Issue bundle now includes a copy of the plugin's persisted
settings (data.json), which maintainers often need to reproduce
configuration-dependent bugs. The copy is sanitized before it is written:
API keys, license keys, tokens, secrets, infrastructure identifiers,
userId, every envOverrides value, and legacy enc_* values are masked as
[REDACTED], so which providers are configured stays visible while the
values never leave the machine. The report dialog, settings description,
and docs now surface that the settings copy is included and redacted.
Saved as data.json.txt for GitHub's attachment allowlist, mirroring the
frame-log workaround.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4ZjSKUSbWyWjx4CLeJS5v
@zeroliu
zeroliu changed the base branch from master to v4-preview July 13, 2026 01:42
@gitguardian

gitguardian Bot commented Jul 13, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
34767473 Triggered Generic High Entropy Secret 2a26ac5 src/utils/sanitizeSettingsData.test.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@zeroliu zeroliu changed the title Add Agent Mode with ACP, Claude, Codex, and OpenCode backends feat(report-issue): bundle sanitized data.json in the Agent Mode issue report Jul 13, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

import { EventEmitter } from "node:events";

P1 Badge Gate node:events import behind desktop check

When the plugin is loaded on mobile or emulated mobile, main.ts imports this module unconditionally, so this top-level node:events import is evaluated before installRendererEventsShim() can return on Platform.isMobile. Because esbuild leaves node:events external, the mobile bundle touches a Node-only module during plugin load and can abort the entire plugin before any Agent Mode code is used; move this import into a desktop-gated dynamic path.


preserveOnViewTypes: [CHAT_VIEWTYPE],

P2 Badge Preserve active web tab for Agent Chat

With only CHAT_VIEWTYPE in preserveOnViewTypes, switching focus from a Web Viewer leaf to the new Agent Chat view triggers recomputeActiveWebTabState's active-leaf-change path to clear activeWebTabForMentions. AgentChatInput reads that same state to show and resolve {activeWebTab}, so the Active Web Tab option disappears and sends omit the web tab whenever the user opens/focuses Agent Chat from Web Viewer; include CHAT_AGENT_VIEWTYPE here as well.


logInfo(`[AgentMode] spawning ${this.opts.command} ${this.opts.args.join(" ")} (tag=${tag})`);

P2 Badge Avoid logging prompt-bearing spawn arguments

Codex appends the composed system prompt as -c developer_instructions=..., so logging args.join(" ") writes the full prompt/user custom instructions to the regular Copilot log every time Codex starts. That log is explicitly saved/opened for sharing from Advanced Settings, so private prompt content can leak even when the user only needs process diagnostics; redact prompt-bearing flags or log only the argv shape.



P2 Badge Exclude agent chats from legacy history modal

Global Agent Mode saves write mode: agent but no projectId, while CopilotPlugin.loadCopilotChatHistory() still feeds the legacy modal with getChatHistoryFiles() and its selection callback calls loadChatHistory directly. Because filterChatHistoryFiles() only excludes projectId/project prefixes, these global agent notes are listed by the Load Copilot Chat Conversation command and then parsed by the legacy chat loader instead of resuming Agent Mode; filter mode: agent out of legacy history or route that modal through loadChatById.


const nextBackends = stripDeviceFieldsFromBackends(agentMode.backends);

P2 Badge Preserve flat agent settings during profile hydration

On a vault that already has flat Agent Mode settings from the pre-device-profile shape (for example agentMode.backends.opencode.binaryPath) but no deviceProfiles[deviceId], profile is undefined and this unconditional strip removes every device-specific field before saveData can move it into the current device segment. The UI then shows the backend as unconfigured and a later save persists the loss; seed the current profile from flat fields when no profile exists before stripping stale values.


* Returns the singleton. `app` is required on the first call (which creates
* the instance) and ignored afterward; the plugin seeds it once at load
* (see main.ts) so subsequent call sites can omit it.

P2 Badge Refresh CustomCommandManager when App changes

After a plugin reload or another lifecycle that constructs a new App in the same renderer, main.ts calls CustomCommandManager.getInstance(this.app) again, but this singleton deliberately ignores the new app once the first instance exists. Because this patch moved create/update/delete paths to this.app.vault and this.app.fileManager, custom command edits after that lifecycle can still target the previous app; update the stored app or reset the singleton on unload.


const existingFile = options?.existingPath
? this.resolveExistingFile(options.existingPath)
: null;
const existingMeta = existingFile ? await this.readExistingMeta(existingFile) : {};

P2 Badge Honor existingPath for hidden agent saves

When defaultSaveFolder is hidden or otherwise not indexed by the vault cache, the first autosave returns an adapter-only path, but the next autosave passes that path through resolveExistingFile() and gets null. The save then regenerates the filename with empty existingMeta instead of updating the pinned file, so a renamed topic can be overwritten and a changed naming template can create a second chat; resolve existingPath via the adapter before falling back to a new name.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@zeroliu
zeroliu marked this pull request as draft July 13, 2026 02:24
Base automatically changed from v4-preview to master August 8, 2026 05:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants