Commit graph

1181 commits

Author SHA1 Message Date
Zero Liu
244fb02455
docs(agent-mode): update Codex ACP setup guidance 2026-07-18 01:35:04 -04:00
Zero Liu
0a0d070cec
feat(agent-mode): surface Codex migration guidance 2026-07-18 01:35:04 -04:00
Zero Liu
8beb2ec124
feat(agent-mode): adopt current Codex ACP runtime 2026-07-18 01:34:52 -04:00
Zero Liu
09081eb4d4
feat(agent-mode): model Codex installation health 2026-07-18 01:34:52 -04:00
Logan Yang
cbfb9f9185
fix(agent-mode): clear composer after sending text with images (#2693)
In Agent Chat, sending text plus attached images posted the turn
correctly but left the text stranded in the input; only the image
previews cleared.

The Lexical editor is controlled: `resetCompose()` sets the compose
value to "", but a selection- or focus-only `OnChangePlugin` change
fired the unchanged editor text back through `onChange`, re-injecting
the stale text over the just-issued clear before `ValueSyncPlugin`
could sync the editor to empty. The async `File.arrayBuffer()` reads
for multiple images widened the window; the first-turn
landing to conversation remount made it easier to hit.

- Add `ignoreSelectionChange` to the shared editor's OnChangePlugin so
  selection/focus-only changes no longer push the (unchanged) text back
  into the controlled value. This closes the race at its source and is a
  strict reduction in redundant onChange work for every consumer.
- Reorder the send handler to clear the composer before awaiting image
  conversion, so it empties the instant the user sends instead of after
  each image decodes.
- Add a regression test locking the clear-before-image-conversion order.

Fixes logancyang/obsidian-copilot-preview#211

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:09:07 -07:00
Logan Yang
418cb85dcd
fix(agent-mode): minimize displayed file targets (#2681) 2026-07-16 14:44:34 -07:00
Zero Liu
0098ba5e2d
fix(agent-mode): keep background task turns alive (#2688)
* fix(agent-mode): keep background task turns alive

* fix(agent): keep Stop hook blocked for running tasks
2026-07-16 14:02:07 -04:00
Logan Yang
4e8e46cbc7
fix(report): redact diagnostic logs before bundling them for a bug report (#2690)
The Agent Mode "Report an Issue" flow copied the frame log and opencode log
into the bundle verbatim (`assembleReportBundle`, via `runtime.copyFile`). Those
logs carry absolute home paths — which expose the OS username, the same class of
leak as unredacted vault paths — and can carry emails or tokens. A user
drag-dropping the bundle into a public GitHub issue would publish all of it.

Now the two logs are read, redacted, and written instead of copied. Redaction is
a new pure util `redactLogText` (src/utils/redactLog.ts): a deterministic
pattern pass (home-dir usernames in Unix/Windows paths, emails, provider key
shapes, bearer tokens, key/secret/password field values → visible markers), fast
and synchronous because a frame log can be tens of MB. It is best-effort by
design; the flow still shows the bundle for the user to review before attaching.

`ReportRuntime` gains `readFile` and drops the now-unused `copyFile`; the two
copy blocks collapse into one redact-and-write loop. The screenshot is still a
binary write, unchanged.

Tests: new redactLog.test.ts covers each pattern, a clean-text no-op, and
idempotency; issueReport.test.ts now asserts the written logs are redacted (home
path and secret gone) and that a log which fails to read is skipped rather than
failing the report.

Related to #213 (hiding absolute paths in Agent Mode), same exposure on the
report surface.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 00:36:31 -07:00
Logan Yang
e89bd562e9
fix(agent-mode): stop cancelled OpenCode retries (#2685)
* fix(agent-mode): stop cancelled OpenCode retries

* fix(agent-mode): serialize follow-ups after cancel

* fix(agent-mode): drain cancelled event tails
2026-07-16 00:27:54 -07:00
Zero Liu
423c456f5f
fix(agent-mode): surface Claude usage errors (#2689)
Claude returns exhausted-usage failures as success-shaped terminal results with is_error set. Treat that terminal contract as a rejected prompt so the provider's reset message reaches the existing chat error path.
2026-07-15 16:46:35 -07:00
Zero Liu
8bf8aec997
test(agent-mode): codify and apply Claude backend test conventions (#2687) 2026-07-15 12:15:21 -04:00
Zero Liu
00b8e32685
fix(agent-mode): surface incompatible agent installs (#2684)
* fix(agent-mode): surface incompatible agent installs

* fix(agent-mode): address install-compatibility review comments

- Run resolver script fallbacks (cli.js / cli-wrapper.cjs) through
  Electron-as-Node in the Claude version probe so a resolver-supported
  install can't be cached as a broken one.
- Clear a backend's settled preload status when it becomes absent so the
  picker stops synthesizing selectable rows for an uninstalled backend;
  incompatible/error installs keep their rows as the route to the
  Upgrade/Configure pill.
- Subscribe the composer's @agent mention list to install-state changes
  so a compatibility probe settling ready surfaces the backend without
  waiting for an unrelated settings write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:52:36 -04:00
Logan Yang
366a3382fb
chore: sync Brevilabs review guidelines into AGENTS.md (#2683)
Synced from Brevilabs/brevilabs-skills references/review-guidelines.md.
Edit the policy there; this fenced block is overwritten on every sync.
2026-07-13 23:56:15 -07:00
Logan Yang
284b25992d
chore: sync Brevilabs review guidelines into AGENTS.md (#2678)
Synced from Brevilabs/brevilabs-skills references/review-guidelines.md.
Edit the policy there; this fenced block is overwritten on every sync.
2026-07-13 13:46:57 -07:00
Emt-lin
41dc2e7835
feat(agent-context): refresh Agent Mode project context when included folder contents change (#2658)
* feat(agent): project-context freshness v1 (minimal watcher + coarse note)

Detect changes to a project's declared context sources with a debounced vault
watcher, mark the project's materialized context dirty via an epoch-salted
revision key, and surface a single coarse "sources may have changed" note to
ongoing and resumed sessions. Stale empty landings are no longer reused on
re-entry, and pending watcher events are flushed at send time.

This is the conservative v1 the maintainer asked for on #2658: broad dirty
notification now, with per-path/binary deltas, resume manifest re-delivery, and
richer source summaries deferred to separate PRs.

- ProjectContentTracker: debounced create/modify/delete/rename watcher, coarse
  per-project scope match (bidirectional folder subtree, conservative tag/folder
  rules, dead-path + internal-file exclusion), per-project content epoch.
- Epoch-salted dirty key + materializer revisionKey so a content edit supersedes
  an in-flight run reading the old content instead of joining it.
- AgentSession: optional sync updates hook injected once per turn, acked only
  after the backend accepts (fan-out never acks); GLOBAL sessions get no hook.
- Resumed sessions force one coarse note on first send; detached stale landings
  are excluded from reuse.

* fix(agent): close v1 freshness review findings

Review pass on the v1 changeset surfaced six items; five fixed, one kept.

- Config-source changes now advance the shared content epoch (via
  ProjectContentTracker.bumpEpoch) before marking dirty, so an ongoing session
  gets the coarse note when a config edit adds a URL/PDF/folder it can't see.
- Flush pending watcher events before the empty-landing reuse decision in
  enterProject and before the epoch/dirty snapshot in createSession, so an
  edit-then-immediately-reopen can't reuse a stale landing or capture an old epoch.
- Tag-declaring projects are now dirtied on folder ops and on a .md->.txt rename
  (markdown relevance checked on both rename sides), and the internal-path guard
  runs before the tag shortcut so a project.md/AGENTS.md edit no longer sprays notes.
- The fresh-session cursor seed is monotonic (never regresses a higher acked epoch).

Kept: the coarse-note wording keeps its capitalized, punctuated sentence form
(semantically identical to the maintainer's descriptive phrasing).

* fix(agent): close same-dimension siblings from freshness verify pass

A verification pass on the review fixes surfaced two open cells in the two
recurring dimensions plus one residual timing window; all closed here.

- event x projection: a folder CREATE (empty directory, no members) no longer
  dirties any project. Snapshot the vault event kind and gate folder-membership
  handling (tag fallback + folder/note/extension match) to rename/delete only.
- carried-state x boundary: resume now flushes pending watcher events before
  reading the revision key, so it can't join a stale in-flight materialization and
  rehydrate the backend with stale searchable roots.
- The fresh-session delivery-cursor seed moved off session.ready onto contextReady
  settlement, so it lands before the session becomes sendable (the post-newSession
  confirmSeededSelection window) — no redundant first-turn note. The dirty CLEAR
  stays on session.ready so a failed backend startup keeps the project dirty.

* docs(agent): design-note the folder-create no-op in the content tracker

Close the last event×projection cell from the verify sweep deliberately: a folder
CREATE — even of a folder that exactly matches a declared folderPattern — stays a
no-op. An empty folder has nothing to materialize, ongoing sessions don't re-mount
roots mid-conversation in v1, and an empty landing's manifest equals a fresh one's;
the first file inside it dirties via the file path and self-heals. Documented so a
future review doesn't re-litigate it.

* fix(agent): dirty on creating an exactly-declared folder source

The prior design-noted no-op was based on a false premise: resolveFolderPaths
turns a declared-but-nonexistent folder from a raw vaultPath into a real manifest
entry (absPath) plus, when out-of-cwd, a mounted additionalDirectories search root
once it exists. So creating an exactly-declared folder DOES change the manifest,
and reusing a stale empty landing would leave the backend without that root (a
later file inside it can mark dirty but can't add a root to an already-open
session). Dirty a project when a folder CREATE path exactly equals one of its
declared folder patterns; a parent/child create resolves no different pattern, so
the tag/note/extension broad fallbacks stay off for CREATE.

* docs(agent): document accepted in-place stale-manifest window

The send-time freshness hook only emits the coarse note; it does not
rebuild a live session's projectContextBlock or additionalDirectories.
Lock that boundary in a DESIGN NOTE at getProjectContextUpdates so the
in-place first-send staleness (recovered on next/re-entered session via
the empty-landing gate) isn't re-litigated. Comment-only; no behavior
change.
2026-07-12 13:11:34 -07:00
Zero Liu
29254f6f16
feat(agent-mode): per-session token/context meter in the composer (#2654)
* 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>
2026-07-08 13:36:11 -07:00
Zero Liu
982a2462f4
Revert "feat(agent-home): surface Relevant Notes as a home-shelf tab (#2639)" (#2667)
This reverts commit 7366499229.
2026-07-07 10:00:26 +08:00
Zero Liu
7366499229
feat(agent-home): surface Relevant Notes as a home-shelf tab (#2639)
* 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>
2026-07-06 16:03:16 -07:00
Emt-lin
f5f38d36e1
fix(agent-mode): measure tab strip synchronously so tabs don't collapse 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.
2026-07-04 14:55:25 -07:00
Emt-lin
358abe6b8d
feat(agent-mode): collapse empty context row, dock status icon in a composer 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
2026-07-04 14:54:49 -07:00
Zero Liu
9fc6153ae6
feat(agent-mode): custom "Other" response option in the AskUserQuestion 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>
2026-07-02 14:03:11 -07:00
Zero Liu
4cfea5761d
fix(agent-mode): stop folding the completed trail into "Worked for X" (#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>
2026-07-02 14:02:54 -07:00
Zero Liu
703529ac67
fix(ui): rebuild SettingSwitch on Obsidian native toggle vars (#2651)
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>
2026-07-02 14:02:23 -07:00
Logan Yang
55c9a2aa0c
Stop the Copilot Plus license hint click from toggling the group (#2649)
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>
2026-06-30 17:57:56 -07:00
Logan Yang
a78b38c8c2
feat(entitlement): gate multi-agent to Plus tier via signed entitlement 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>
2026-06-29 19:56:00 -07:00
Zero Liu
bd60f10f56
fix(agent-mode): copy/insert the full agent response, not just trailing 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>
2026-06-29 19:44:04 -07:00
Logan Yang
60dc554c1f
feat(model-management): add DeepInfra Copilot Plus models, default off (#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>
2026-06-29 18:52:44 -07:00
Zero Liu
83f1f7070e
feat(models): flag non-vision models, guard image sends (#2627)
* 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>
2026-06-29 00:02:47 -07:00
Logan Yang
9b4ae5721a
fix(agent-mode): ship Windows cmd/ps1 runtime for builtin relay skills (#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>
2026-06-28 01:27:44 -07:00
Emt-lin
6d878e175c
feat(agent-projects): project-scoped Agent Mode — Agent Home PR2 (#2604)
* 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).
2026-06-28 00:33:22 -07:00
Logan Yang
f10430aea2
fix(agent-mode): allow exec/skill tools in read-only fan-out QA turns (#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>
2026-06-22 22:19:24 -07:00
Logan Yang
93727cde34
fix(agent-mode): restore inline code background when reloading a chat (#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>
2026-06-22 21:55:54 -07:00
Logan Yang
f4da806b1c
feat(agent-settings): tab the Agents settings into per-backend panels (#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>
2026-06-22 20:31:04 -07:00
Logan Yang
769b8724bf
feat(agent-mode): explicit per-agent Default model setting (#2631)
* 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 61c9948d, 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 2c234d45 (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 32c13fea (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>
2026-06-22 19:37:53 -07:00
Logan Yang
6ecea570f1
fix(agent-mode): hide Recent Chats entries not resumable on this device (#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>
2026-06-22 14:43:40 -07:00
Logan Yang
0aa3a92766
fix(agent-mode): highlight active multi-agent tab + readable summary (#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>
2026-06-22 13:08:43 -07:00
Logan Yang
f071eb896c
feat(agent-mode): shimmer the multi-agent QA "thinking" status text (#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>
2026-06-21 21:44:14 -07:00
Logan Yang
2cf80bc411
feat(agent-mode): multi-agent per-turn QA (#57) (#2628)
* 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>
2026-06-21 20:40:30 -07:00
Wenzheng Jiang
16fedfbae1
Revert "feat(agent-mode): add miyo-chat-search skill for searching saved AI chats (#2621)" (#2625)
This reverts commit 8db705f147.

Co-authored-by: Wenzheng Jiang <wenzhengjiang@Wenzhengs-Mac-mini.local>
2026-06-19 12:50:11 +09:00
Wenzheng Jiang
8db705f147
feat(agent-mode): add miyo-chat-search skill for searching saved AI chats (#2621)
Splits AI-chat-history search into its own builtin skill so each Miyo
skill has a focused "when to use" signal: miyo-search stays vault-wide,
while the new miyo-chat-search targets the user's synced ChatGPT/Claude
conversations.

The chat skill ships a two-step workflow: miyo-folders.* lists indexed
folders so the agent can identify chat folders by "origin": "chat_sync"
(matched on origin, not hardcoded names), then miyo-chat-search.* runs a
search scoped to them via repeatable --path filters forwarded to
`miyo search`. Binary resolution is shared across all wrappers.

miyo-search is restored byte-identical to its prior vault-only form
(version unchanged); the host now seeds and prunes both Miyo skills
together, gated on Miyo being in use.

Co-authored-by: Wenzheng Jiang <wenzhengjiang@Wenzhengs-Mac-mini.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:20:53 +09:00
Logan Yang
87317ed263
fix(agent-mode): bundle the frame log as .txt so GitHub accepts it (#2624)
GitHub's issue attachment allowlist rejects `.ndjson`, so the diagnostic
frame log could not be dragged into a report. Save the bundled copy as
`acp-frames.ndjson.txt` (an allowed type, keeping `.ndjson` in the name
as a format hint); content is unchanged. The loose files stay exactly as
the user reviews them, so editing/redacting them before attaching works
as the modal warning says.

Fixes https://github.com/logancyang/obsidian-copilot-preview/issues/155.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:49:08 -07:00
Logan Yang
f75c2ebbee
feat(agent-mode): one-click "Report an Issue" flow with screenshot + frame log (#2614)
* feat(agent-mode): one-click "Report an Issue" flow with screenshot + frame log

Reporting an Agent Mode bug was high-friction: the diagnostic frame log
defaulted off (so it usually wasn't capturing when a problem hit), and there
was no in-product path to assemble a report.

- Default `agentMode.debugFullFrames` to ON for new installs so the frame log
  is already capturing when a bug occurs. The existing sanitize migration
  preserves an explicit prior choice (a user who turned it off stays off).
- Add a "Report an Issue" button to the Agent Mode control bar. It opens a
  modal for a note, captures a screenshot of the chat surface (Electron
  capturePage, popout-aware, degrades gracefully), bundles it with the current
  frame log into a timestamped folder, reveals the folder, and opens a
  prefilled GitHub issue.
- Optionally include the OpenCode log when that backend is active, and show an
  in-flow privacy disclosure shown only when the user chooses to share.

Desktop-only; mobile and capture failures degrade to a report without a
screenshot. Adds unit tests for the new default, the preserve-explicit-choice
migration, the bundle assembler, and the opencode-log locator.

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

* fix(agent-mode): make opencode log attachment opt-in by default

The Report-issue flow bundles opencode's newest *global* log, which may
belong to an unrelated CLI/Desktop session for another project. Defaulting
the checkbox to checked could silently attach that log, exposing unrelated
prompts and tool output. Default it to unchecked so the user opts in.

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

* fix(agent-mode): cap report issue URL length and document the flow

- buildReportIssueUrl: truncate the prefilled body so the assembled URL
  stays under Electron's ~2081-char openExternal limit on Windows. Without
  this, a long note made openExternal reject silently while the success
  notice still claimed the issue page opened. The full report is preserved
  in report.md on disk, and the truncated body points the user there.
- Document the Report an Issue flow (bundle contents, drag-drop attach,
  privacy note, opt-in OpenCode log) in docs/agent-mode-and-tools.md per
  DOCS_GUIDE.

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

* fix(agent-mode): skip frame log in report when logging is disabled

If a user turns off "Log Full Agent Mode Frames" but a stale
acp-frames.ndjson still exists in the temp dir, the report assembler would
copy that old file into the bundle, leaking plaintext prompts/tool output
despite logging being opted out. Only bundle the frame log when
debugFullFrames is currently enabled.

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

* fix(agent-mode): file reports to public repo; clarify report UX and settings

Addresses review feedback on the Report an Issue flow:

- File user reports to the PUBLIC logancyang/obsidian-copilot repo (label
  "bug"), never the private preview repo, which users can't see.
- Report modal: state plainly that the screenshot is of the Agent Mode chat
  pane (not the whole screen), spell out what "Prepare report" does (save
  files to a folder, open it, open a prefilled GitHub issue to attach them),
  and replace the obscure muted disclaimer with a prominent warning callout.
- Advanced settings: split Agent Mode logging into its own "Agent Mode
  debugging" section with plain-language copy (renamed to "Keep an Agent Mode
  activity log" / "Agent Mode activity log file"), and clarify that the legacy
  Debug Mode / Create Log File tools are for the regular chat, not Agent Mode.
- Update docs to match.

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

* feat(agent-mode): move Report an Issue to the Advanced settings section

Per UX feedback, the entry point moves out of the agent chat control bar into
Settings → Advanced → Agent Mode debugging, alongside the activity-log
controls it relates to.

- Remove the bug-icon button and its wiring from AgentChatControls/AgentHome.
- Add a "Report an Issue" button to the Agent Mode debugging settings section.
- ReportIssueModal now resolves its screenshot target lazily via
  resolveCaptureTarget(): the settings caller closes the Settings window and
  reveals the agent pane first, so the screenshot is still the chat surface
  (not the dialog). No agent pane open -> screenshot is skipped gracefully.
- Export ReportIssueModal from the agentMode barrel so host code can reach it
  without crossing the ui boundary.
- Update docs to point at the new location.

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

* fix(agent-mode): honor opencode env overrides when finding its log

OpencodeBackend spawns opencode with `{ ...process.env, ...envOverrides }`, so a
user who relocates opencode's data dir via XDG_DATA_HOME/HOME overrides has its
logs written there. The report's opencode-log lookup only consulted ambient
process.env/homedir, so it could attach an unrelated global log or miss the
active session's. Resolve the log path from the same merged env + HOME override.

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

* fix(agent-mode): report uses active session backend; gate import on desktop

Two review fixes for the settings-based Report an Issue button:

- Read the backend from the active Agent Mode session, not the persisted
  default. Switching tabs across backends changes the active session without
  touching settings.agentMode.activeBackend, so the modal could hide/show the
  OpenCode-log option for the wrong tab and name the wrong backend in the env
  block.
- Check isDesktopRuntime() before importing the @/agentMode barrel. On mobile
  the barrel evaluates Node-only modules; importing before the modal's own
  desktop guard could reject during module load instead of showing the
  desktop-only notice. Mirrors the existing frame-log buttons.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:43:26 -07:00
Logan Yang
decca79498
fix(skills): make the built-in miyo-search skill reliably invokable (#2613)
* fix(skills): make the built-in miyo-search skill reliably invokable

Smaller/local models were giving up on miyo-search: the prose-only skill told
the agent to try `miyo` on PATH first, then fall back to the absolute path
across OS variants. In Obsidian-launched shells PATH is reduced, so the first
attempt fails and the model concludes the tool is unavailable instead of
recovering. (A user's hand-written custom skill that calls
`~/.miyo/bin/miyo search` directly works every time — isolating the problem to
the built-in skill's construction.)

Ship a runnable wrapper (`miyo-search.sh` + Node `miyo-search.mjs`, mirroring
the Plus relay skills) that resolves the binary itself — absolute install path
first (`~/.miyo/bin/miyo`, `%LOCALAPPDATA%\Miyo\bin\miyo\miyo.exe`), PATH as a
fallback — and runs one `miyo search … --json`. The SKILL.md "How to run" is now
a single command; binary/OS branching lives in the script. Bumped the builtin
version (1 → 2) so existing installs re-seed.

Verified both scripts against the live miyo binary under a reduced PATH (the
exact failure scenario): both resolve the absolute path and return valid JSON.

Closes logancyang/obsidian-copilot-preview#161

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

* fix(skills): honor custom Miyo URL and resolve Windows path in the sh wrapper

Addresses two Codex review points on the miyo-search wrapper:

- Custom/remote Miyo: shouldUseMiyo seeds the skill for users with a custom
  server URL (incl. mobile), but the wrapper ran a bare local `miyo search`.
  Inject MIYO_URL (which the CLI reads) from the configured custom URL in
  buildCopilotPlusEnv, so the agent transparently targets the right service —
  no script change needed. Independent of Plus (self-host can use Miyo).
- Windows + Git Bash prefers the .sh, but it only checked the POSIX install
  path; add cygpath-based resolution of %LOCALAPPDATA%\Miyo\bin\miyo\miyo.exe
  so it no longer falsely reports "not installed" there.

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

* docs(skills): steer Windows to the Node miyo-search wrapper

On Windows the `.mjs` invokes the Miyo executable directly (no POSIX shell,
native %LOCALAPPDATA% resolution), which is more reliable than `.sh` (only runs
under Git Bash). SKILL.md now tells the agent to prefer sh on macOS/Linux and
node on Windows; either still works on any platform as a fallback. The
.sh cygpath branch stays as defense-in-depth for Git Bash users.

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

* fix(agent-mode): restart backends when the Miyo server URL changes

MIYO_URL is injected into the managed spawn env, so it's only read on a fresh
spawn. A mid-session change to the Remote Miyo Server URL previously only hit
restartSystemPromptAffected(), a no-op while Miyo stays enabled (the prompt key
doesn't include the URL), so a running codex/opencode subprocess kept querying
the old server. Treat a miyoServerUrl change like a Plus-license change and
restart every backend so the new URL takes effect without a reload.

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

* fix(skills): ship a Windows .cmd wrapper so Miyo works without sh or node

A managed-opencode Windows session can be ready from the downloaded native
binary while neither Git Bash (`sh`) nor `node` is on PATH. The v2 reseed left
only `.sh` + `.mjs`, so those users were told to install Node even though Miyo
installs a runnable `%LOCALAPPDATA%\Miyo\bin\miyo\miyo.exe` (the v1 prose could
call it directly). Add `miyo-search.cmd` — runnable from cmd or PowerShell with
no extra runtime — which resolves the exe under %LOCALAPPDATA% then PATH and
runs `miyo search … --json`. SKILL.md now documents the sh → node → cmd chain.

The version stays 2; the seeder's missing-support-file check reseeds existing
v2 installs to pick up the new script.

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

* refactor(skills): drop the Node miyo wrapper — sh (POSIX) + cmd (Windows) suffice

The Windows .cmd (always present, calls the local miyo.exe directly) is a
strictly better Windows fallback than the Node .mjs, which made Node redundant
for this skill. Ship exactly two OS-matched wrappers and let the SKILL.md map
OS → script with no runtime choice:

- macOS/Linux → miyo-search.sh (POSIX-only; the cygpath/Windows-path branch is
  gone since Windows no longer uses .sh)
- Windows → miyo-search.cmd

Removes the Node dependency, the cygpath path-translation, and the 3-way runtime
selection — fewer moving parts and failure modes. MIYO_URL env injection and the
restart-on-URL-change still apply to both wrappers.

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

* fix(skills): document the PowerShell call operator for the cmd wrapper

A bare quoted path is a string in PowerShell — it won't execute, and vault
paths often contain spaces, so an agent running the example from PowerShell
would stop before reaching miyo-search.cmd. Show the call-operator form
(& "...\miyo-search.cmd" "...") and note cmd omits the &.

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

* docs(skills): don't claim miyo search is always local

With MIYO_URL injection a configured Remote Miyo Server URL sends the search to
that server, so "All calls are local — no network" was inaccurate and could make
the agent misrepresent where a query goes. Reword: searches go only to the
user's own Miyo service — the local app by default, or their configured remote
server — never a third-party API.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:48:15 -07:00
Logan Yang
f55514bdb3
Add Copilot Plus web-fetch skill and stop hard-blocking free users on web tools (#2612)
* feat(agent-mode): add Copilot Plus web-fetch skill and stop hard-blocking free users on web tools

Adds a `copilot-web-fetch` builtin agent skill wrapping the firecrawl-backed
`/url4llm` relay endpoint, so Copilot Plus users' agent-mode page fetches use
the higher-quality Plus fetch instead of the agent's own WebFetch.

Reworks the shared skill-script preamble so a missing or invalid license no
longer forces an "upgrade to Copilot Plus" reply. When no license is configured
(free user) the script now tells the agent to silently fall back to its own
built-in web tools and never refuse, appending a gentle upsell only ~1 in 4
runs (keyed off the process id) so the nudge stays occasional. A 401/403 from
the relay (lapsed Plus) gets a distinct, still-non-blocking renewal note.

Updates the system-prompt steering with a web-fetch bullet and softened,
never-block fallback wording. Bumps all relay skill versions so seeded copies
refresh.

Closes logancyang/obsidian-copilot-preview#160

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

* fix(agent-mode): genericize Plus-inactive fallback message and add license-key CTA

The shared no-license/invalid-license message is embedded in copilot-read-pdf
as well as the web skills, so telling the agent to "use your own built-in web
tools" misdirected the recovery path for a local PDF (per Codex review). Make
the fallback generic: complete the request with your own equivalent built-in
tools if you have them, otherwise tell the user it's unavailable. Mirror the
same wording in the system-prompt steering.

Also add a clear, concise "get a license key ... to access them" call to action
to the occasional upsell.

* fix(agent-mode): route non-license web-fetch relay failures to the native tool

Per Codex review: an active Plus user fetching a page only got the fallback for
missing/disabled/inactive-license cases. A non-401/403 relay failure (the page
can't be fetched, a transient 5xx, or the relay is unreachable) emitted a bare
"Request failed (HTTP ...)" and dead-ended — even though the steering now routes
page reads away from the agent's native WebFetch.

Both the sh and node relay error paths now append a fallback hint telling the
agent to use its own equivalent tool, and the system-prompt steering's fallback
clause covers "fails for this particular request" in addition to the unavailable
cases.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:36:34 -07:00
Logan Yang
367aedddaf
feat(agent-mode): warn about privacy terms on free opencode models (#2611)
* feat(agent-mode): warn about privacy terms on free opencode models

Free opencode models (big pickle, grok-code, the *-free variants) are
routed through third-party providers whose terms commonly allow logging
or training on prompts, but the model picker gave no signal. Each free
model now shows a warning icon + privacy tooltip in the Agent Mode picker.

"Free" is derived generically from the models.dev catalog cost
(input === 0 && output === 0), so any future free model is flagged with no
hardcoded id list.

To make cost reach agent-discovered opencode rows, AgentSetupApi's catalog
lookup now also keys models by the provider-qualified `<provider>/<model>`
form: opencode reports prefixed wire ids (`opencode/big-pickle`) while the
catalog keys models bare (`big-pickle`), so the prior bare-only lookup
missed and never snapshotted cost. The resolved `ModelInfo.id` is pinned to
the requested wire id so enriched rows keep their prefixed identity (model
selection / reconcile unaffected). Reconcile also refreshes `cost` in place,
so already-enrolled rows backfill the free signal on the next discovery sync
(runs on app load) with no migration code.

Closes logancyang/obsidian-copilot-preview#159

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

* feat(agent-mode): show free-model warning in the Agents settings model list

Extend the free-model privacy warning to the per-model toggle rows in
Settings -> Agents (the opencode model enable list), not just the chat
model picker. Same generic cost-based signal.

Factor the shared pieces out: `isFreeModelCost` (catalog-cost predicate) into
modelManagement, and the warning copy into components/ui/freeModelWarning so
both the picker and the settings list use one string. The settings row carries
an `isFree` flag computed in `toRow`, and ModelEnableList renders the same
AlertTriangle + HelpTooltip beside the label.

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

* fix(agent-mode): exclude self-hosted/local models from the free-model warning

Local endpoints (Ollama, LM Studio) often match a zero-cost catalog entry,
so they were incorrectly flagged with the free-model privacy warning. The
warning is about prompts leaving the machine to a third party, which doesn't
apply to self-hosted models.

Gate the free flag on `!isSelfHostedProvider(provider)` at both computation
sites (opencode picker entries and the settings model rows). opencode's
agent-origin native provider has no baseUrl, so opencode Zen free models
(big pickle, grok-code) stay flagged; a localhost baseUrl is excluded.

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

* refactor(agent-mode): mark free models by opencode Zen membership, not models.dev cost

Switch the free-model warning to opencode's own signal: a model is flagged when
its wire-id prefix is `opencode/` (opencode Zen, opencode's self-hosted free
tier), straight from what opencode reports. This drops the entire models.dev
cost pipeline (catalog isFree, AgentSetupApi qualified-key lookup + cost
reconcile, self-hosted gating) added earlier.

Why: opencode's ACP report carries no cost/free flag, and models.dev cost==0
caught local open models too (LM Studio gpt-oss etc. showed the warning).
opencode Zen membership is the precise, opencode-native signal — it marks Big
Pickle and the *-Free Zen models and excludes the lmstudio/ sub-group and every
other backend.

Also fix the hover error (`isShown is not a function`): replace the Radix
HelpTooltip + aria-label on the icon with a native `title` attribute via a
shared FreeModelWarningIcon, used by both the chat picker and settings list.

Caveat: pure membership would also flag a paid Zen model if opencode ever
reports one; the free tier is all that surfaces today.

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

* fix(agent-mode): show free-model tooltip instantly on hover

The native `title` attribute has a fixed ~1s delay. Switch FreeModelWarningIcon
to the Radix HelpTooltip with delayDuration={0} so it appears immediately. No
aria-label on the icon (that path triggers Obsidian's native tooltip and the
`isShown` error); the tooltip content carries the message.

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

* refactor(agent-mode): trim the free-model warning implementation

- Inline the warning string into FreeModelWarningIcon and drop the separate
  freeModelWarning.ts module (it had a single consumer).
- Drop the unused OPENCODE_ZEN_PROVIDER_ID barrel export.

The opencode-Zen flag still originates in the opencode backend
(opencodeEnabledModelEntries / toRow) and is carried to the UI as a plain
`isFree` boolean: the eslint-boundaries `ui -> backend` rule forbids the UI
from importing the opencode detection helper directly, so the flag-on-entry
plumbing is the boundary-correct shape, not incidental.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:23:25 -07:00
Logan Yang
09cb6bb136
fix(agent-mode): restore user message text when a recent chat has image attachments (#2610)
A native Claude chat reopened from recent chats rebuilds its transcript
from the on-disk CLI .jsonl. parseClaudeTranscript only accepted user
records with string content, treating all array content as a tool_result
and skipping it. A user message with an attached image is logged with
array content (text + image blocks), so the entire user turn — including
its text — was dropped, leaving an orphaned assistant reply.

Now array user content is kept unless it carries a tool_result block: its
text blocks are restored and images are omitted from the display.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 00:25:01 -07:00
Logan Yang
4c3044f4b5
fix(agent-mode): show attached images in the posted user message (#2609)
* fix(agent-mode): show attached images in the posted user message

Attached images were delivered to the model but never appeared in the posted
user bubble: sendPrompt stored the user message without a `content` field and
forwarded the image promptContent blocks only to the backend. The renderer
(ChatSingleMessage) shows images only from `content` entries shaped
`{ type: "image_url", image_url: { url } }`.

Project the outgoing image promptContent blocks into that display shape
(base64 -> data URL) and store them as the user message's `content`, leaving
the backend promptContent path untouched. Markdown autosave still serializes
only message text, so saved notes are not bloated with base64.

Closes logancyang/obsidian-copilot-preview#157

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

* fix(agent-mode): keep prompt text visible alongside attached images

Address Codex review: when a user message has a content array, ChatSingleMessage
renders text only from a `type: "text"` content item. buildUserDisplayContent
emitted image_url entries only, so a mixed text+image prompt hid the user's
text. Include the prompt text as the first content entry (text-first, then
images, mirroring the legacy chat composer); image-only messages still omit the
text entry.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:53:09 -07:00
Logan Yang
894c21d056
fix: unblock eligible self-host users' own web search and YouTube tools (#2606)
* fix: unblock eligible self-host users' own web search and YouTube tools

Self-host tools (web search, YouTube transcription) require
isSelfHostModeValid() = enableSelfHostMode toggle AND a valid validation
receipt (selfHostModeValidatedAt + selfHostValidationCount). The toggle
and receipt are stored separately and could disagree: the legacy
enableSelfHostedSearch -> enableSelfHostMode migration in sanitizeSettings
seeded the toggle but never the receipt, so isSelfHostAccessValid()
returned false on the selfHostModeValidatedAt == null check. As a result
YouTube hard-returned "requires a Copilot Plus subscription" and web
search fell through to the Brevilabs backend (MissingPlusLicenseError),
even for eligible Believer/Supporter users with their own keys.

Defensively seed the validation receipt in sanitizeSettings: whenever the
sanitized result has enableSelfHostMode === true but
selfHostModeValidatedAt == null, set selfHostModeValidatedAt = Date.now()
and selfHostValidationCount = max(existing || 0, 1). This is placed after
the legacy migration so the migrated toggle value is already applied, and
it covers both the migration gap and any "toggle on but receipt null"
state. We never seed when the toggle is off, so users who never enabled
self-host mode are not granted access, and periodic
refreshSelfHostModeValidation() still disables/clears for genuinely
ineligible plans.

Scope is the tools path only; agent-mode self-host (src/agentMode/skills)
is tracked separately in #146.

Adds unit tests for the sanitize seeding (migration + toggle-on-null-
receipt + the off-toggle guard + no-overwrite) and for the
isSelfHostModeValid()/isSelfHostAccessValid() paths in plusUtils.

Closes logancyang/obsidian-copilot-preview#145

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

* refactor(self-host): gate tools on the toggle, drop validation-receipt seeding

Per preview-phase policy (no migration/temporary logic), replace the defensive
receipt seeding in sanitizeSettings with a toggle-only gate.

isSelfHostModeValid() now returns enableSelfHostMode === true. The startup
refreshSelfHostModeValidation() still disables the toggle for genuinely
ineligible plans, so the toggle is a sufficient gate. This removes the buggy
seeding that re-stamped Date.now() on every restart (never persisted, so the
15-day grace renewed indefinitely) instead of persisting it.

isSelfHostAccessValid() and the grace/permanent machinery are left intact since
miyoUtils still depends on them; ripping those out is a follow-up.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:10:21 -07:00
Logan Yang
e1abb9259b
fix(agent-mode): correct session tab titles for codex and Claude Code (#2607)
* fix(agent-mode): correct session tab titles for codex and Claude Code

codex named sessions after the raw first prompt, leaking the injected
<copilot-context> envelope into the tab title, and Claude Code fell back to the
backend display name ("Claude") because its SDK exposes no title API. Only
opencode summarizes its own titles.

Add a required `summarizesSessionTitle` flag to BackendDescriptor (opencode:
true, codex/claude: false). Backends that summarize keep trusting their
session/list and pushed session_info_update titles; the rest now derive the tab
label client-side from the user's first visible message (reusing
deriveChatTitleFromMessages) and ignore backend-provided titles. The derived
title is agent-sourced, so a user Rename still wins and the first message's
title is stable across later turns.

Closes logancyang/obsidian-copilot-preview#113

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

* fix(agent-mode): skip native title sweep for non-summarizing backends

Address Codex review: sweepNativeSessions() merged listSessions titles into the
recent-chats index for every backend, so a codex session whose first prompt
carried attached context could still be discovered (and reopened) with the raw
<copilot-context> title, bypassing the AgentSession-level gating.

Gate the sweep on summarizesSessionTitle too: non-summarizing backends (codex,
Claude Code) are skipped entirely since the sweep has no transcript to derive a
clean title from. Their sessions are still indexed via flushIndexTouch, which
uses the client-derived title.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:45:16 -07:00
Logan Yang
641bfadb7a
fix(agent-mode): coalesce streamed-token re-renders (#2602)
Agent Mode rebuilt the entire chat transcript on every streamed-token
notification: AgentMessageStore.getDisplayMessages() mapped over and
re-adapted every visible message into fresh objects each call, so the
memoized AgentChatMessages component (and every AgentTrail under it)
re-rendered once per token. On a fast token stream that drove one full
main-thread re-render per token, causing lag between Enter and the user
message appearing and stuttering of the CopilotSpinner sigma dots.

Streamed-token notifications were already rAF-coalesced at the
AgentSession boundary (scheduleNotifyMessages), with discrete events
(send, turn-complete, error, permission) flushing immediately via
notifyMessages(). The remaining cost was the per-notification rebuild in
the store. Memoize getDisplayMessages() so identity is stable:

- Each stored message carries a `version` bumped on every in-place
  mutation; the adapted AgentChatMessage is cached by version, so an
  unchanged message keeps the SAME object reference across ticks and its
  memoized React component skips re-rendering.
- The returned array is itself cached and only rebuilt after a mutation,
  so an idle notification returns the exact same reference and the
  top-level messages memo bails out without diffing.

Streaming correctness is preserved: the store still mutates synchronously
so no tokens are dropped, and the trailing turn-complete notification
still flushes immediately (notifyMessages cancels any pending streaming
rAF), so the message always settles to its complete final state.

Tests: store-level memoization (stable identity for unchanged messages,
re-adaptation after each mutation kind, no-op upsert preserves identity,
cache eviction on delete/truncate) and session-level coalescing
(burst-of-chunks collapses to one notification per frame; the trailing
turn-complete notification is always delivered and cancels the stale
pending frame).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:11:16 -07:00