9 Commits
Author SHA1 Message Date
e2bbb0295a feat: smart auto-routing (per-turn simple-vs-strong model selection) (#1734)
* feat(smart-routing): add smartRouting settings schema and reader

* feat(smart-routing): resolve role keys to a SmartRoutingConfig

* feat(smart-routing): wire per-user-turn routing into the query loop

Classify once per user turn (transition===undefined), pin the decision in a
loop-local, and apply the model-only route before the blocking-limit math.
Enforce the org allowlist by calling isModelAllowed directly (coerce disallowed
to strong; disable for the session if strong is also disallowed). Strip thinking
history on a model change only under the provider gate (preserve-reasoning
providers are left untouched). Export stripThinkingBlocksIfProviderAllows.

* feat(smart-routing): add routed-error fallback to the strong model

A simple-routed turn whose model call hits a retryable error retries once on
the strong model, reusing the existing attemptWithFallback retry loop. Aborts
and 4xx client errors propagate. Adds a session routing tally (simple/strong
counts and simple->strong escalations) for the observability surface.

* feat(smart-routing): add /smartroute command and env defaults

/smartroute shows status and sets/toggles the simple and strong roles from
agentModels keys, warning when the simple model is not first-party-cheaper than
the strong one. OPENCLAUDE_SMART_ROUTING(_SIMPLE/_STRONG) provide startup
defaults; an explicit settings block overrides env.

* feat(smart-routing): show routing summary in /cost

Appends a session routing summary (turns simple/strong, simple->strong
escalations) to /cost, with an estimated-savings line gated on first-party
pricing and annotated unavailable for unknown third-party pricing. Per-turn
cost is already attributed to the routed model via the existing per-model
breakdown.

* fix(smart-routing): re-pin to strong after a routed-error fallback

Without this, a turn's later continuation passes re-applied the pinned simple
model after a fallback, re-triggering the same failure each pass. Re-pinning to
strong keeps the rest of the turn on the recovered model.

* fix(review): provider-swap guard, tally reset, notice-storm, env docs

- Add the KTD6 provider-swap guard: drop the per-turn routing pin when a
  mid-turn provider-fallback swap changes the active provider, so the old
  provider's model id is not replayed at the new endpoint (adversarial P1).
- Reset the routing tally in resetCostState() so /cost does not show stale
  cross-session counts.
- Don't emit the disabled-for-session notice on every turn when no sessionId
  is available (suppress instead of storm).
- Document OPENCLAUDE_SMART_ROUTING* in the openaiShim env-var header.
- Add tests: provider-swap-safe pin, undefined-session silence, /smartroute
  strong arm and no-value guard.

* docs(smart-routing): document /smartroute, settings, and env vars

Register /smartroute in the web command catalog, add the smartRouting setting
and OPENCLAUDE_SMART_ROUTING* env vars to the configuration reference, add a
docs/smart-routing.md usage guide, and link it from the README.

* fix(review): clear tally on /login, extract+test swap predicate, cap disabled set

- /login used the raw bootstrap resetCostState, leaking the routing tally
  across an account switch; switch it to the cost-tracker wrapper.
- Extract the provider-swap drop check as a pure, tested
  shouldDropPinForProviderSwap() and use it in the query loop.
- Cap the disabledSessions set so a long-lived host can't grow it unbounded.
- Document the 404/429 retry-by-design rationale; add tests for it.
- Clarify the routedFallbackUsed per-turn scope and the apply-after-guard
  comment; document cross-provider role rejection and the re-enable path.

* test(smart-routing): make allowlist tests robust to cross-file module mocks

The decideTurnModel allowlist tests spied the global settings singleton, which
let another file's leaked mock.module of modelAllowlist (agent.test.ts) flip
isModelAllowed out from under them in the full suite. Spy isModelAllowed
directly and restore it in afterEach so the tests are deterministic regardless
of suite ordering.

* fix(smart-routing): address CodeRabbit review and green CI

- index.test.ts: pin the allowlist in the three happy-path decideTurnModel
  tests so they no longer inherit a leaked cross-file isModelAllowed mock
  (the CI test failure)
- smartroute/index.test.ts: narrow the LocalCommandResult union via an
  expectText helper instead of reading .value off the union (the CI
  typecheck failure)
- conversationRecovery.ts: route deserialize's thinking-strip gate through
  stripThinkingBlocksIfProviderAllows, removing the duplicated provider
  detection
- conversationRecovery.test.ts: replace the two as-any fixtures with a
  shared typed factory

* fix(smart-routing): scope cost claims to first-party reference pricing

Smart routing's savings estimate and "simple isn't cheaper" warning were
derived from the static first-party MODEL_COSTS table via getKnownInputCost,
with no knowledge of the active provider, gateway, or account pricing. For a
multi-provider user whose model ids happen to exist in that table but bill
differently, the /cost summary and /smartroute warning stated a savings figure
as if it reflected what they are actually charged.

Narrow the copy instead of inventing provider-aware pricing the code cannot
verify: the /cost line, the /smartroute warning, and docs/smart-routing.md now
label the numbers as first-party reference pricing and note the active provider
may bill differently. Tests assert the qualifier on every reworded branch so it
cannot silently regress. No routing logic changed.

* fix(smart-routing): clarify simple role wording

* Fix smart routing review findings

* fix(smart-routing): honor env roles and non-text turns

* test(smart-routing): cover non-text skip path

---------

Co-authored-by: jatmn <the@jat.mn>
2026-07-07 10:48:52 +08:00
5471e4c453 feat(agent-routing): assign a per-agent model from the /agents menu (#1632)
* feat(agent-routing): add user-settings route read/write helpers for the /agents UI

* feat(agent-routing): add AgentRouteSelector UI for picking a per-agent model route

* feat(agent-routing): open the model-route selector from the /agents detail view

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>

* fix(agent-routing): scope route options to user settings, align cross-provider labels

Build route options from getSettingsForSource('userSettings') instead of
getInitialSettings(), matching the scope we persist to. Reading from the
merged view could surface an agentModels key that only exists in a
non-user scope; selecting it would write a shadow user-settings entry that
diverges from the original (losing its base_url/api_key).

Label a key as cross-provider when either base_url or api_key is present,
matching readAgentRoute, so the same route is described consistently.

Also assert the dangling case in the currentRouteValue test, which the
old test name referenced but did not cover.

* fix(agent-routing): commit custom model id on submit, match runtime cred rule

The custom-model input persisted from the per-keystroke onChange, so typing
the first character of a model id saved that single character and closed the
selector. Track the typed value and persist only on submit (the sentinel
fires the Select onChange with the full value).

Cross-provider detection now mirrors the runtime resolver (toAgentRoute):
a route is cross-provider only when both base_url and api_key are present. A
partial entry is skipped at runtime and inherits, so readAgentRoute reports it
as unconfigured (dangling) and buildRouteOptions labels it 'unconfigured,
inherits' rather than claiming a cross-provider route that will not execute.

* fix(agent-routing): resolve the picker against the runtime's effective route key

The runtime resolver normalizes routing keys (case-insensitive,
hyphen/underscore-equivalent) and falls back to default, but the picker
read and wrote agentRouting with the exact agentType key. So an existing
general_purpose route showed general-purpose agents as inheriting, and
selecting a model wrote a general-purpose sibling that first-wins lookup
ignored while the menu claimed the change took effect.

readAgentRoute now matches the normalized per-agent key the resolver
would use, and surfaces a default-fallback route with viaDefault.
computeSetRouteUpdate/computeClearRouteUpdate overwrite or clear that
existing key spelling instead of writing a sibling. The clear option is
hidden for default-inherited routes since there is no own key to remove.

* fix(agent-routing): do not save routes a higher-priority source overrides

The runtime resolver reads merged settings (userSettings ->
projectSettings -> localSettings -> flagSettings -> policySettings), but
the picker read and wrote only userSettings. A project/local/policy
agentRouting entry made /agents report the agent as inheriting, and
selecting a model wrote a userSettings route the merged chain ignored
while the menu reported success.

getAgentRoute now reads the effective merged settings, so the route line
reflects what runtime resolves. setAgentRoute/clearAgentRoute refuse with
an explanation when a higher-priority source owns the normalized route
key, and the picker surfaces that as a read-only notice instead of
offering an edit that cannot take effect.

* fix(agent-routing): source-aware shadow guidance + selector tests

flagSettings has no file to edit (it comes from the --settings flag or
SDK inline settings), so the shadow message no longer tells users to edit
a nonexistent file. Extracted shadowRemediation so the error and the
read-only selector notice share one source-aware string.

Added focused AgentRouteSelector tests covering the shadow read-only mode
(file-backed and flag sources), persisting a selected model, and surfacing
a failed save without closing the dialog.

* test(agent-routing): make selector set-route test deterministic

The persist test selected an option by ordinal without controlling the
option list and only asserted the agentType. Mock buildRouteOptions to a
known single option and assert the full [agentType, modelKey] tuple, so
the test proves the selected model key is what gets persisted.

* test(agent-routing): stop selector mock leaking into sibling test

bun's mock.module persists across files in the same process and
mock.restore() does not undo it, so mocking buildRouteOptions in the
selector test leaked into agentRouteSettings.test.ts and failed its
buildRouteOptions assertions in the full suite (passed in isolation).
Re-point the module at the real implementation in afterEach, matching the
AgentsMenu.test.tsx pattern.

* test(agent-routing): use real buildRouteOptions in selector test

Faking buildRouteOptions via mock.module leaked into agentRouteSettings.test.ts
in the full suite: bun live-updates the imported namespace, so the afterEach
re-mock to that namespace re-installed the fake. The real buildRouteOptions is
deterministic (built-in aliases list sonnet/opus/haiku first), so option 1 is
'sonnet' without any fake. Only the I/O wrappers stay mocked.

* test(agent-routing): pin mock restore to a pre-mock exports snapshot

Snapshot the real agentRouteSettings exports into a frozen const before the
first mock.module call and spread/restore from that, instead of the live
realRouteSettings namespace bun mutates when the module is mocked.

* fix(agent-routing): resolve alias routes provider-aware, guard disabled user settings

Two review findings:

- A model-only route whose model is a built-in alias (sonnet/haiku/opus/inherit)
  was sent literally as mainLoopModel, bypassing getAgentModel()'s provider-aware
  fallback. On non-Claude-native providers that 404s. resolveAgentRunModelRouting
  now runs a bare-alias model-only route through getAgentModel (parentModel +
  permissionMode threaded from the callers), so it inherits the parent model the
  same way the agent model selector does. Real model ids pass through unchanged.
- setAgentRoute/clearAgentRoute now refuse with an explanatory error when
  userSettings is not an enabled setting source (e.g. --setting-sources project),
  instead of writing a route the runtime will never load and reporting success.

* fix(agent-routing): apply model-only routes to teammate spawns

Pane/window teammates resolve their model through
resolveOutOfProcessTeammateProvider, which returns only cross-provider
overrides. A model-only agentRouting route (the common case the /agents
menu writes) was dropped, so a routed teammate type with no explicit
model inherited the parent instead of the saved route.

Add resolveOutOfProcessTeammateModelOnly, the model-only twin of the
provider resolver, mirroring runAgent's lookup order and provider-aware
alias resolution, and consult it in the teammate spawn path when there
is no cross-provider override. Enforce the model allowlist on the
resolved model when it changes the effective model.

Also add the permission-mode regression CodeRabbit requested: spy on
getAgentModel to assert the mode is forwarded into alias resolution.

* fix(agent-routing): guard agentModels key shadowing and honest clear label

The picker builds options from userSettings, but agentModels merges by
source priority, so a user-level route to a key that a higher source
(project/local/flag/policy) also defines resolves to that higher entry,
not the current-provider model the option promised. getRouteShadowSource
only checked agentRouting keys, so this collision was not surfaced.

Add findModelKeyShadowingSource / getModelKeyShadowSource and refuse to
save a model-only route whose key is shadowed by a higher source (unless
the user already owns that key in userSettings). buildRouteOptions now
flags shadowed keys so the conflict is visible before selecting.

Also fixes the clear-route label: clearing only removes the agent's own
routing key, so when a default route is configured the agent falls back
to the default, not the parent model. The label and PR description now
say so instead of promising parent inheritance.

* fix(agent-routing): reject shadowed model keys even when userSettings owns them

The previous shadow guard skipped the check when userSettings already
defined agentModels[modelKey], but that does not make the route take
effect: agentModels merges by source priority, so a higher-priority
project/policy entry for the same key still wins and the agent resolves
to that provider/model. The picker even flagged the key as shadowed
while the save path let it through, so offer and save disagreed.

Drop the ownsKey carve-out so setAgentRoute rejects whenever a higher
source defines the key. Extract collectShadowedModelKeys as the single
definition of 'defined above userSettings' that both the offer flag
(getShadowedModelKeys) and the save guard (findModelKeyShadowingSource)
derive from, and add a test locking the two paths in agreement.

* test(agent-routing): cover setAgentRoute shadow guard and model-only teammate spawn

Adds direct setAgentRoute() coverage for the higher-priority-shadow rejection
and the allowed user-only-key save, and an AgentTool teammate-spawn regression
that a model-only route reaches spawnTeammate as the resolved model with no
cross-provider override.

* test(agent-routing): cover custom model id submission persists full id

* test(agent-routing): match focus glyph cross-platform in custom-id test

The custom model regression test waited for the ❯ focus pointer, but ink
renders figures.pointer as ">" on Windows, so the wait timed out in a
Windows checkout before typing the id. Match figures.pointer directly so
the wait tracks whatever glyph the renderer emits on each platform.

* fix(agent-routing): let the route picker own Esc while open

AgentDetail kept its parent confirm:no (Esc -> onBack) handler active after
switching into routing mode. Since the Confirmation context was registered
first, a bare Esc resolved to confirm:no and exited the detail view instead
of resolving to the nested select's select:cancel, despite the picker copy
telling users Esc goes back. Gate confirm:no with isActive=!routing so the
route selector owns Esc and a single Esc only closes the picker. Add an
AgentDetail regression covering m then Esc.

* fix(agent-routing): scope Esc to the route picker and keep in-process teammate routes

Two follow-ups from review of the per-agent model routing UI.

The /agents detail view wraps AgentDetail in a Dialog whose own confirm:no
(onCancel) stayed active while the route picker was open. As the
first-registered Confirmation context it swallowed Esc and dropped back to the
agent list instead of just closing the picker. AgentDetail now reports its
picker state up through onRoutingChange, and a small AgentDetailDialog wrapper
feeds that into the Dialog's isCancelActive so the picker's Select owns Esc
while it is open.

In-process teammates run the same runAgent() as normal subagents, but the
synthetic agent definition overwrites agentType with the teammate's display
name, so the original subagent_type that agentRouting is keyed on was lost and
the configured cross-provider route never resolved (the teammate ran the routed
model on the parent provider). The original subagent_type is now carried through
the in-process handoff and used as the routing key, matching what pane and
window teammates already get by re-resolving from their CLI identity.

Adds a regression test for the picker Esc interaction through the Dialog wrapper
and resolver tests proving the route resolves from the original subagent_type
rather than the teammate name.

---------

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-06-18 08:46:51 +08:00
beardthelionandGitHub d5588ea80d feat(context-collapse): opt-in between-turns context collapse (span summarization) (#1619)
* feat(context-collapse): implement context collapse for proactive context management

* feat(context-collapse): add turn-boundary helpers for span selection

* feat(context-collapse): deterministic turn-anchored span selection

* feat(context-collapse): code-computed span risk score

* feat(context-collapse): ctx-agent summarization instruction

* feat(context-collapse): implement ctx-agent span summarization spawn

* fix(context-collapse): make runtime activation opt-in (CLAUDE_CONTEXT_COLLAPSE)

* fix(context-collapse): address review feedback on restore state and test rigor

- restoreContextCollapseState now resets armed/lastSpawnTokens up front so a
  snapshot-less restore cannot carry stale spawn state across sessions.
- projectView reuses a stable timestamp from the replaced span instead of
  new Date(), keeping the read-side projection deterministic.
- Strengthen the disabled-state and turn-boundary assertions, drop an internal
  renderToolUseMessage assertion, and isolate the operations/persist/spawn tests
  from shared module and CLAUDE_CONTEXT_COLLAPSE env state.

* test(context-collapse): re-init enablement in persist.test hooks

resetContextCollapse() does not re-read CLAUDE_CONTEXT_COLLAPSE, so the
afterEach env delete left enabled=true in module state, leaking to the
next test file. Call initContextCollapse() in both hooks so module
enablement stays synced to the env var.

* test(context-collapse): stop spawnCtxAgent module stubs leaking across files

spawnCtxAgent.test.ts stubs shared modules (tokens, forkedAgent, messages,
analytics, log, spanSelection) via mock.module in beforeEach. bun's
mock.restore() does not undo mock.module, so the tokens stub (() => 100000)
bled into autoCompact/microCompact/runAgent tests run later in the full serial
suite, making them see every conversation as over-threshold (4 spurious
failures in test:full, all green in isolation).

Restore each stub to its real implementation in afterEach. The reals are
snapshotted into plain objects up front because 'import * as' yields a live
namespace that mock.module mutates in place, so holding the namespace would
restore the stub. autoCompact.js is deliberately not restored here since
autoCompact.test.ts re-imports it fresh via a cache-busting nonce.

Also reset+reinit the collapse module in afterEach so enabled state stays
synced to the now-unset env var.

* test(context-collapse): also restore autoCompact stub from spawnCtxAgent

The getEffectiveContextWindowSize stub on ../compact/autoCompact.js was the one
module the previous commit left unrestored, on the assumption that restoring it
would clash with autoCompact.test.ts's nonce re-import. It doesn't: the nonce
import uses a different specifier, and the snapshot restore is keyed by the
plain specifier. compressToolHistory imports getEffectiveContextWindowSize and
sizes tool-history truncation from it, so the leaked 20000-token window made it
fully omit tool results ('chars omitted') instead of mid-truncating
('[…truncated') for large-context models, failing the openaiShim compression
tests in the full serial suite. Restore all seven mocked modules.

* fix(context-collapse): re-arm after reset and gate ctx_inspect on opt-in

resetContextCollapse() left armed=false while enabled stayed true, so the
first /compact, main-thread compaction cleanup, or rewind permanently
disabled collapse for the rest of an opted-in session. Reset now mirrors
restoreContextCollapseState and sets armed=enabled.

CtxInspectTool.isEnabled() returned true unconditionally, advertising
ctx_inspect to the model in every default session even when the runtime
opt-in was off. It now returns isContextCollapseEnabled(). The opt-in is
also exposed as the contextCollapseEnabled global config key, so it is
reachable through /config instead of only the CLAUDE_CONTEXT_COLLAPSE env
var.

* refactor(context-collapse): drop no-op ternary in drainStaged persist call

The (stagedQueue.length > 0 ? 0 : 0) subtrahend always evaluated to 0, so
this is just persistCommits(processed.length).

* fix(context-collapse): persist commits before advancing the snapshot

drainStaged removed processed spans from the staged queue and then fired
persistCommits and persistSnapshot in parallel. If the snapshot write (which
no longer lists those spans as staged) landed while the commit write failed
or the process died between them, restore would find the spans neither staged
nor committed and the collapse would disappear on resume. Chain the snapshot
write after the commit write so the commit log is durable first.

* fix(context-collapse): project committed collapses on the query path, fix opt-in reach

Three issues from review:

- Committed collapses were never re-applied to the model input. The query path
  calls applyCollapsesIfNeeded but only drained staged spans; projectView (which
  replays the commit log) ran only in /context. Since messagesForQuery is rebuilt
  from full REPL history each turn and the commit log is repopulated on resume,
  the archived spans returned to the model on the next turn, undoing the collapse.
  applyCollapsesIfNeeded now runs projectView first (idempotent). Adds a
  regression that a committed collapse changes the next query input.

- Cache-safe params were saved only for exact repl_main_thread/sdk sources, but
  the REPL tags non-default output styles as repl_main_thread:outputStyle:*, so
  those sessions left the ctx-agent without params (empty spawns). Matches
  repl_main_thread:* now, via a small tested helper.

- contextCollapseEnabled had no settings control. Adds a /config toggle that
  refreshes runtime state (re-runs initContextCollapse) so it applies without a
  restart.

* fix(context-collapse): clear already-committed staged spans; harden config toggle

After projecting committed collapses before draining, a span present in both the
commit log and the staged snapshot (a restore whose snapshot predates the
matching commit write) could not be drained — projectView had already removed
its messages — so it lingered in stagedQueue and distorted spawn/overflow
checks. drainStaged now drops staged spans that are already committed and syncs
the snapshot. Adds a regression covering the committed+staged overlap restore.

Also wraps the /config context-collapse refresh in try/catch so a failed
require/init can't crash the settings UI, and lists the toggle in the
save-and-close change summary like the neighboring compaction settings.

* fix(context-collapse): re-sync runtime state on config cancel

The context-collapse toggle's onChange refreshes the module-level
enabled/armed cache via initContextCollapse(). The revert path restored
the config key on disk but left that cache untouched, so enabling the
toggle and then pressing Escape kept collapse active for the rest of the
session. Re-init context collapse after the global config snapshot is
restored so cancel fully reverts runtime state.

* fix(context-collapse): keep collapsed summaries visible to the model

projectView and drainStaged replaced an archived span with a system
informational placeholder, but normalizeMessagesForAPI filters out every
system message that is not a local command. So once a collapse committed,
the next model request lost both the archived messages and the
<collapsed> summary meant to stand in for them, defeating the feature.

Mark the placeholder with isCollapseSummary and let it take the same
model-input path as local-command system messages (converted to a user
message), so the summary survives normalization. Added a regression that
runs the projected view through normalizeMessagesForAPI and asserts the
summary is still present.

* fix(context-collapse): avoid competing snapshot write after drain

After an immediate post-spawn drain, drainStaged(messages, true) starts
its own persistCommits().then(persistSnapshot) chain to guarantee commit
durability before the snapshot stops listing the staged spans. The
unconditional await persistSnapshot() that followed could win that race
and persist a snapshot with no staged spans before the commits landed,
reopening the crash window that drops collapses on restore. Only persist
directly when nothing was drained.

* fix(context-collapse): fall back, keep summaries non-snippable, gate /context

Three review findings:

- Suppress autocompact and the blocking preempt only when collapse holds a
  real committed/staged reduction, not on mere enablement. Adds
  hasActiveReduction(); a first over-threshold turn where spawnCtxAgent cannot
  produce a span (getLastCacheSafeParams() still null) now falls back to
  autocompact/blocking instead of sending an oversized transcript.
- Preserve isMeta when converting a collapse-summary placeholder to a user
  message in normalizeMessagesForAPI, so the HISTORY_SNIP sweep cannot tag the
  only replacement for an archived span as snippable.
- Gate the two /context projectView calls on isContextCollapseEnabled(), so a
  disabled session does not under-report token usage from a lingering commit
  log while the API receives the full transcript.

Adds regressions for hasActiveReduction and for the summary surviving
normalization as a non-snippable meta message.

* fix(context-collapse): scope collapse to the main thread that owns the store

The collapse store (commitLog/stagedQueue) is module-level and shared by
in-process subagents (agent:*) and the ctx-agent (marble_origami), which
run in the same process but do not own the main transcript.
applyCollapsesIfNeeded only skipped marble_origami, so a subagent could
stage or commit a span, flip the global hasActiveReduction(), and make
the next main-thread turn suppress autocompact and the blocking
prompt-too-long preempt while projectView() no-ops against the main
messages, sending an oversized transcript to the API.

Add isMainThreadSource() and gate both application (applyCollapsesIfNeeded,
isWithheldPromptTooLong, recoverFromOverflow) and fallback suppression
(autoCompact shouldAutoCompact, query collapseOwnsIt) to the owning
thread. Subagents now autocompact and preempt their own oversized turns
normally and never mutate the shared store.

Also adds the staged-only hasActiveReduction regression CodeRabbit
requested.

* fix(context-collapse): persist archived count so resumed stats stay accurate

restoreContextCollapseState rebuilt each commit with an empty archived
list, and getStats summed that list, so after a resume /context, the
context visualization, the token warning, and ctx_inspect reported
'N spans summarized (0 messages)' even though projectView was actively
removing the archived spans. The persisted-entry docstring claimed
projectView lazily refills the archive, but it only splices by boundary
uuid and never does.

The archived messages are never read back (only their count fed
getStats), so replace the per-commit Message[] with a persisted
archivedCount. It is written with each commit and restored on resume;
pre-field sessions restore as 0. getStats now reports the same figure
live and after resume.

* fix(context-collapse): keep collapse summary non-snippable across user merge

Preserving isMeta on the system->user conversion was not enough: when the
collapsed span ends right before the next user turn, normalizeMessagesForAPI
merges the summary into that real user message. Under HISTORY_SNIP
mergeUserMessages clears isMeta whenever an operand is real user content and
keeps the real turn's uuid, so the combined block — which carries the only
<collapsed> replacement for the archived span — got a snip id and the model
could queue it for removal.

Carry an isCollapseSummary marker onto the converted user message and through
mergeUserMessages (either operand), strip any snip id already baked into the
real turn when the merge absorbs a summary, and skip such blocks in
appendMessageTagToUserMessage. The merged block stays non-snippable
regardless of merge direction or isMeta being cleared.

* fix(context-collapse): preserve collapse marker on split, drop empty snip blocks

normalizeMessages split path now forwards isCollapseSummary so an array-backed
collapse summary keeps its non-snippable marker across API normalization.
stripSnipTagsFromContent drops a text block whose only content was the snip
marker, so the merge recovery path no longer emits an empty text block.
2026-06-17 11:02:54 +08:00
beardthelionandGitHub 716c1d47f6 feat(compact): auto-compact prompt on /resume + determinate progress bar (#1386)
* feat(compact): auto-compact prompt on /resume + determinate progress bar

On /resume, if the conversation exceeds 70% of the auto-compact threshold,
a dialog appears offering to compact before continuing. Shows token count,
context window usage, and effective window percentage. Also adds a
determinate progress bar during compaction that advances as the summary
streams in.

- Add RESUME_COMPACT_PROMPT feature flag (enabled)
- Add shouldPromptCompactOnResume() threshold gate
- Add ResumeCompactPrompt dialog component
- Emit compact_progress events during streaming in compact service
- Render ProgressBar next to spinner during compaction in REPL
- Add 5 tests for threshold gating logic

* feat(compact): determinate progress bar via streamed text deltas

Wire compact_progress events from streamed summary output (forkedAgent
onStreamEvent forwards text deltas) and render a determinate ProgressBar
in the REPL, replacing the indeterminate spinner during compaction.
Extracts the bar into a CompactProgressBar component shared by manual
/compact and the resume-triggered path. Emits coarse hooks/start ticks
on the session-memory path so the bar moves immediately.

* fix(compact): keep spinner visible and use bracketed progress bar

Render the "Compacting conversation" spinner throughout compaction with
the progress bar beneath it, instead of swapping the spinner out for the
bar. Redraw the bar as a bracketed fill ([███····]) with no background
rectangle, so it no longer reads as one solid block.

* docs: remove resume-compact-prompt plan file

Per PR review feedback — drop the planning doc from the PR.

* fix(compact): prompt to compact on CLI --continue/--resume startup

Startup resume paths (--continue, --resume <id>, ResumeConversation
screen) install initialMessages via the initial useState rather than
the resume() callback, so the threshold check never ran. Schedule the
prompt from the mount-time initialMessages effect as well.

* fix(compact): use MessageType alias for resumeCompactPending state

main aliases the message type import as `Message as MessageType`; the
rebased resumeCompactPending state still referenced the bare `Message`.

* fix(compact): always close session-memory progress and fix progress-ratio units

- Wrap the session-memory compaction attempt in try/finally so compact_end
  is always emitted, even when it returns null or throws, preventing a stuck
  progress bar/spinner.
- Clear compactProgressRatio in resetLoadingState so an aborted or errored
  compaction does not leave the progress bar rendered in the idle UI.
- Fix the progress denominator unit mismatch: estimatedOutputChars now uses a
  token-to-char converted estimate (preCompactTokenCount) instead of the
  token-scale preCompactTokenCount * 0.25, so progress no longer advances too
  fast and hits the cap prematurely.
2026-06-14 11:26:28 +08:00
beardthelionandGitHub 9e902db866 feat(agent-routing): model-only agent routes (set the verifier or any agent's model on the current provider) (#1617)
* feat(agent-routing): allow model-only agentModels entries in settings schema

* feat(agent-routing): resolve model-only agent routes that reuse the current provider

* feat(agent-routing): enforce org model allowlist for model-only agent routes

* docs(agent-routing): document model-only routes and built-in agent keys

* test(agent-routing): stub and assert partial-entry routing warnings

Silence the intentional console.error noise from partial agentModels
entries in CI logs by stubbing console.error per describe block and
asserting the expected warning message fires, so future routing
warnings or failures are not masked.

* test(agent-routing): consolidate shouldEnforceModelAllowlist import

Move the import to the top import block instead of mid-file.
2026-06-14 10:10:37 +08:00
beardthelionandGitHub cdc8057496 feat: enable HISTORY_SNIP — model-callable snip tool for context management (#1407)
* feat(snip): implement HISTORY_SNIP — model-callable snip tool for context management

- snipProjection.ts: boundary detection + view filter (isSnipBoundaryMessage, projectSnippedView)
- snipCompact.ts: pending registry, snipCompactIfNeeded, shouldNudgeForSnips, SNIP_NUDGE_TEXT
- SnipTool/: model-callable snip tool with Zod schema (prompt.ts + SnipTool.ts)
- types/message.ts: add SystemCompactBoundaryMessage export
- scripts/build.ts: enable HISTORY_SNIP: true
- QueryEngine.ts: fix snipReplay return type

* docs: add MCP_SKILLS implementation plan

* docs: add HISTORY_SNIP implementation plan

* fix(snip): prune headless store on snip-boundary replay

The snipReplay path called snipCompactIfNeeded with a {force:true} option
that the function never read, and the pending-snip set was already cleared
when the boundary was produced in query.ts — so the replay always reported
nothing removed and mutableMessages never shrank in long SDK sessions.
Prune the store by the boundary's own removedUuids via projectSnippedView
instead. Also drop two planning docs that were committed to the branch.

* fix(snip): persist snip boundary in SDK/headless transcripts

When a snip boundary was yielded in the SDK/headless path, snipReplay pruned
the in-memory mutableMessages store but the branch broke before adding the
boundary to the local messages array or calling recordTranscript. Later
transcript writes used the pre-snip messages copy, so the on-disk transcript
kept the removed messages and no snipMetadata boundary. After a restart or
--resume, loadTranscriptFile reconstructed the un-snipped history and the
context reduction was lost.

Mirror the boundary into the local messages copy and record it when the snip
executes, matching the compact_boundary path. recordTranscript is append-only
by UUID, so the pre-snip messages already on disk remain and the appended
boundary (carrying snipMetadata.removedUuids) lets applySnipRemovals prune
them on load.

Add a loadTranscriptFile round-trip test covering the previously-untested
snip replay: a persisted boundary prunes its removedUuids and relinks
survivors whose parentUuid pointed into the removed gap.

* fix(history-snip): record paired tool-result removals and scope pending snips per conversation

Two issues in the snip path:

1. Persist every removed message. snipCompactIfNeeded drops the paired
   tool-result user messages of a snipped assistant tool-use message from the
   live context, but the boundary only recorded the explicitly-marked UUIDs.
   projectSnippedView / loadTranscriptFile replay solely from
   snipMetadata.removedUuids, so on --resume the tool results came back orphaned
   (their assistant message stayed removed) and part of the reduction was lost.
   Record the paired tool-result UUIDs in removedUuids so replay drops the same
   set the live snip dropped.

2. Scope pending snips per conversation. The pending registry was module-global
   and stored model-facing short IDs, then cleared unconditionally on every
   snipCompactIfNeeded pass. With concurrent in-process sessions, session B could
   clear A's pending IDs (losing A's snip) or, on a short-ID collision, prune the
   wrong message. Resolve short IDs to full UUIDs at mark time against the
   snipping conversation's own messages, and consume only the UUIDs present in
   the current message array. UUIDs are globally unique, so the registry
   self-scopes: one session can no longer consume or mis-target another's.

* fix(history-snip): drop paired assistant tool-use when snipping a tool-result message

[id:] tags are appended to user messages only, so the model snips a
tool-result user message, not the assistant tool_use. The previous
pairing only ran assistant->user; snipping a tool-result left the
preceding assistant tool_use orphaned, so the next API-prep pass
synthesized a placeholder result and the tool interaction was never
actually removed from live context or from replay.

Pair in both directions: when a snipped user message's tool_results all
belong to an assistant turn, drop that assistant tool_use too (mirroring
the existing .every() guard so partially-snipped turns are kept), and
record its UUID in the boundary's removedUuids so replay drops the same
set.

* fix(history-snip): add SnipBoundaryMessage render component

HISTORY_SNIP ships enabled, so Message.tsx reaches the snip_boundary
render branch after the first snip. That branch requires
./messages/SnipBoundaryMessage.js and renders its named
SnipBoundaryMessage export, but no source file existed — the build
emitted a missing-module-stub exporting only a default noop, so the
named component was undefined and the render crashed right after a
successful snip.

Add the component, mirroring CompactBoundaryMessage: a single dimmed
line marking the snip with the removed-message count and the transcript
shortcut. The build now resolves the import (no stub) and the named
export is present in the bundle.

* build: guard against enabled-feature imports resolving to missing-module stubs

The missing-import scanner stubs any unresolved relative import to a noop
default export. For a require behind a DISABLED feature flag that is correct
(dead-code-eliminated, never bundled). But when a flag is ENABLED the gated
require becomes live and the stub silently degrades a real module to
() => null, so a named export resolves to undefined and crashes the first
time that path runs. SnipBoundaryMessage shipped exactly this way: build,
smoke, and unit tests all passed while the UI crashed on the first snip.

Feature-flag DCE removes disabled branches before bundling, so every
missing-module-stub marker left in dist/cli.mjs is reachable in the shipped
build. After the CLI bundle, fail the build on any stub marker not explicitly
grandfathered in ACCEPTABLE_RUNTIME_STUBS (seeded with the pre-existing
stubs), and warn on stale allowlist entries. Verified: removing the
SnipBoundaryMessage source makes the guard fail and name the module.

* fix(history-snip): drop unmirrored force-snip command registration

force-snip is gated on HISTORY_SNIP but its source (./commands/force-snip.js)
was never mirrored into this build, so require(...).default resolved to the
missing-module stub's noop. That truthy noop was spread into the command list
(commands.ts:252), registering a bare () => null as a command with no name,
description, or call — broken the moment anything enumerates commands. Enabling
HISTORY_SNIP turned this live, same class as the SnipBoundaryMessage crash.

Remove the registration rather than ship a phantom command: the implementation
is not present in this tree, so the honest behavior is to not register it. Drop
the matching ACCEPTABLE_RUNTIME_STUBS entry so the bundle guard stays strict.

* fix(history-snip): don't snip a tool result that would orphan a surviving tool_use

The result-side pairing dropped an explicitly-snipped tool-result user
message even when its paired assistant turn had other, un-snipped tool
calls. That left the assistant holding a tool_use with no matching result,
which the next API-prep pass repairs with a synthetic placeholder
(src/utils/messages.ts), so the snip never actually took effect and the
restored context still carried the stale interaction.

Block-level surgery on the surviving assistant is not an option: replay
(projectSnippedView / loadTranscriptFile) drops whole UUIDs, not blocks, so
the live store and a --resume would diverge. Instead, treat an unclean snip
as a no-op: a tool_use is safely removable only if its whole assistant turn
goes with it (the assistant is explicitly snipped, or every tool_use in it
has its result snipped). A tool-result user message whose results don't all
pair to a removable tool_use is kept, and no boundary is emitted when nothing
was cleanly removable, keeping live context and replay identical.

* docs(build): document the bundle-stub guard as a coarse tripwire

The guard rationale claimed every missing-module-stub marker left in
dist/cli.mjs is reachable in the shipped build and that each allowlisted
entry is latent runtime debt behind an enabled flag. That overstates it: the
scanner keys missing modules by specifier string, so a same-named specifier
missing in one importer (including a test file) can leave a marker even when
another importer resolves the real module, and a marker can sit on a path
that never runs. Reword the comment and error message so a flagged stub reads
as "inspect this", not "confirmed runtime crash"; the guard reliably catches
a NEW stub appearing where none was expected, which is its actual value.

* fix(build): canonicalize bundle stub markers before diffing the allowlist

The bundle guard compared raw `missing-module-stub:` marker text against
ACCEPTABLE_RUNTIME_STUBS, but the marker format is not stable across build
hosts: locally Bun emits the relative import specifier
(`./commands/fork/index.js`), while on the Linux CI merge run it emitted the
same grandfathered stubs as absolute source paths
(`/home/runner/work/openclaude/openclaude/src/commands/fork/index.ts`). The raw
diff therefore failed `bun run smoke` on CI for already-allowlisted stubs and
also reported them as stale.

Canonicalize both the bundle markers and the allowlist to a stable key (the
basename without extension) before diffing, so a stub matches in either form.
Basename is the only reduction that unifies a relative specifier of unknown
depth with an absolute path (a fixed path-segment count breaks single-segment
specifiers like `./dream.js`). The allowlist keeps the readable full specifiers;
diagnostics still print the raw marker. Guard against two allowlist entries
sharing a basename (which would let one silently cover an unrelated stub) by
failing the build if the canonical set is smaller than the allowlist.

* chore(build): drop allowlist stubs resolved by current main

Rebasing onto current main brings in the per-importer scanner (#1399)
and the real sources for four previously-stubbed modules, so they no
longer emit missing-module markers:

  - ../../utils/hooks/ssrfGuard.js   (per-importer keying, #1399/#1450)
  - ./dream.js                       (/dream restored, #1399)
  - ./UserForkBoilerplateMessage.js  (source mirrored, #1451)
  - ./commands/fork/index.js         (unmirrored /fork dropped, #1451)

The bundle guard flagged all four as stale allowlist entries. Remove
them and refresh the guard rationale comment, which described the
pre-#1399 specifier-string scanner; the scanner now keys per importer.

* fix(history-snip): expose snip id on pure tool-result messages

appendMessageTagToUserMessage() only appended the [id:...] tag to a
string body or an existing text block. A user message that is purely
tool_result blocks (the normal shape for large Read/Bash outputs) has
no text block, so it returned unchanged and carried no visible id. Those
are exactly the highest-value snip targets the feature prompts the model
to remove, yet the model had no id to reference them by.

Append a dedicated text block holding the tag when a tool-result-only
message has no text block. The tool_result block is left intact, so snip
pairing is unaffected, and the tag lands on the API-bound copy only.

Export the function and add colocated tests covering string body, text
block, the pure tool_result case, and meta passthrough.

* fix(build): key bundle-stub guard on repo-relative path, not basename

The guard canonicalized every missing-module-stub marker to its basename
before checking the allowlist, so a future stub named constants.ts (or
cachedMCConfig.ts, MonitorMcpDetailDialog.ts) from any other directory
would be treated as allowlisted and slip past the guard — the exact
regression class the guard exists to catch.

Post-#1399 the per-importer scanner records each stub as the resolved
absolute source path, which differs across build hosts only by the
repo-root prefix. So key on the repo-relative path from src/ onward
(without extension): stable across hosts yet path-specific, so a stub
cannot mask a same-named file elsewhere. Drop the now-moot basename
collision guard and store the allowlist as repo-relative keys.

* fix(history-snip): describe snip as a queued, refusable request

SnipTool's tool result said "Marked N message(s) for removal. They will
be removed from context before the next model call" based only on the
count of input IDs. But snipCompactIfNeeded() can refuse the exact
request on the next turn: it keeps a tool_result whose paired tool_use
would survive (snipping it would orphan the tool call), freeing 0 tokens
and emitting no boundary. The model was told the output would be removed,
then saw it still in context with no failure signal, so it treated a
structural no-op as a successful context reduction.

Reword the tool result to describe the snip as a queued request that may
be refused, name the one refusal condition (would orphan a paired tool
call, e.g. one result from a parallel-tool turn), and give the model the
observable signal and repair: a kept message re-shows its [id:...] tag
next turn (tags are re-applied every API-prep pass), and snipping all of
that turn's tool results together removes them cleanly.

Add SnipTool.test.ts pinning the queued/refusable wording.

* test(history-snip): import UserMessage from its canonical module

messages.snipTag.test.ts imported UserMessage from ../query.js, which
imports the type but does not re-export it (TS2459). Import it from
../types/message.js, the canonical source messages.ts itself uses, so
the snip test files typecheck cleanly.

* fix(history-snip): make snip id tag injection idempotent

appendMessageTagToUserMessage() documents that it only mutates the
API-bound copy, but query.ts builds the next loop state's toolResults
from normalizeMessagesForAPI([update.message]) (query.ts:1589) and stores
that normalized, already-tagged output into state.messages
(query.ts:1976). With HISTORY_SNIP enabled the tag is carried forward as
conversation state, so the next turn re-normalizes it and appends the
same [id:...] a second time. In multi-tool agent loops every prior tool
result accumulates another duplicate tag each iteration, bloating context
and showing the model repeated IDs that are meant to be an API-projection
affordance only.

Guard the append: if the message already carries its own [id:<id>] token
(string body, last text block, or the dedicated tool_result text block),
return it unchanged. The token is derived from the message's own uuid, so
its presence means it was already tagged. Adds 3 idempotency tests.

* fix(history-snip): expose every parallel-tool sibling id before merge

normalizeMessagesForAPI tagged snip [id:] markers only after merging
consecutive user messages. A parallel-tool assistant turn yields several
adjacent tool_result user messages; the merge keeps just the first
operand's uuid, so on the resume/reload path (where the persisted
transcript is the untagged original) only the first sibling's id reached
the model. snipCompactIfNeeded refuses to drop one result of such a turn
(it would orphan the surviving tool_use), so the model needed every
sibling's id to request the whole-turn removal the snip prompt instructs,
and could never form it: a permanent no-op.

Inject the tag per user message before the merge instead, so each
sibling carries its own [id:] and joinTextAtSeam preserves them all,
matching the live path where each result is tagged at push time. The
post-merge sweep stays (idempotent) to tag user messages synthesized
during normalization (local_command, attachments).

Test: merging tagged parallel siblings keeps every sibling id and both
tool_result blocks.

* test(history-snip): type snip-replay test ids as UUID

loadTranscriptFile() returns Map<UUID, TranscriptMessage>, but the test
id() helper returned plain string, so every messages.has/get/
buildConversationChain call in the persisted-snip replay test raised a
TS2345 against the UUID-keyed map. Type id() as UUID (casting the literal
once at the source) so the new replay coverage does not add touched-path
typecheck debt. Also clears the same error cluster in the pre-existing
compact-boundary tests that share the helper.

* docs(history-snip): drop removed /force-snip from setMessages comment

The QueryEngine setMessages comment cited /force-snip as its example of a
message-mutating slash command, but that command was removed. Point the
example at /clear (src/commands/clear/conversation.ts), which still mutates
the message array via setMessages, so the comment stays accurate.

* refactor(history-snip): type SnipBoundaryMessage removedUuids as string[]

removedUuids holds message UUID strings throughout the snip feature, but
the SnipBoundaryMessage prop typed it as unknown[]. Narrow it to string[]
so the type carries intent and the test fixture no longer needs an
`as never` cast to satisfy the prop (the cast bypassed type checking and
could have hidden a real fixture/prop mismatch).

* fix(history-snip): drop stale cachedMCConfig stub-allowlist entry

cachedMCConfig.ts now exists in the tree and bundles as real code, so it
is no longer emitted as a missing-module stub. The grandfathered baseline
listed it among acceptable stubs, which made the new guard print a stale
warning and, worse, would silently accept a future reintroduced
cachedMCConfig stub as known debt instead of flagging it. Drop the entry so
the allowlist matches the actual bundle (VerifyPlanExecutionTool/constants
and MonitorMcpDetailDialog).

* fix(history-snip): guard paired snip drops and report queued count

Two CodeRabbit findings on the snip compaction path:

- Mixed-content turns: the inferred paired-drop ran its .every() check over
  filtered tool blocks only, so an assistant turn like [text, tool_use] (or a
  user [tool_result, text]) was treated as fully droppable and its text was
  silently removed when the paired half was snipped. Require the whole message
  to be tool blocks before an inferred drop; otherwise treat the snip as a
  no-op (the explicit-snip path, where the model deliberately targets a message,
  is unchanged and still removes wholesale).

- Queued count: markForSnip only enqueues short IDs it can resolve against the
  conversation, but SnipTool reported sniped = input.message_ids.length, which
  overstated the result when IDs were stale or unresolvable. markForSnip now
  returns the distinct resolved UUIDs and SnipTool reports that length.

* fix(history-snip): align snip prompt with queued-not-guaranteed contract

The tool description told the model snipped IDs are "permanently remove[d]
... before the next model call", but snipCompactIfNeeded queues the request
and keeps a message when removing it would orphan a paired tool_use (the
tool_result already says so). Match the description to that contract so the
model does not treat a structural no-op as a guaranteed removal.
2026-06-08 11:58:59 +08:00
beardthelionandGitHub b900364dbe fix(fork): render forked-worker messages, drop unmirrored /fork command (#1451)
* fix(fork): add UserForkBoilerplateMessage, drop unmirrored /fork command

FORK_SUBAGENT ships enabled, which made two missing-module paths live:

1. UserTextMessage renders <UserForkBoilerplateMessage> whenever a user
   message contains <fork-boilerplate> (produced by
   forkSubagent.buildChildMessage). No source file existed, so the build
   stubbed the import to a noop default and the named component was
   undefined, crashing the render of any forked-worker message.
2. The /fork slash command required ./commands/fork/index.js, whose source
   was never mirrored; its noop .default was spread into the command list,
   registering a command with no name/description/call.

Add the component, rendering a compact dimmed marker with just the
directive (parsed off FORK_DIRECTIVE_PREFIX) instead of dumping the verbose
worker rules block into the transcript. Remove the /fork registration:
the implicit-fork machinery (AgentTool/forkSubagent) is present and keeps
working; only the unmirrored slash command is dropped.

Verified: both stubs gone from dist/cli.mjs, the real component is bundled,
smoke passes, and component + commands tests pass.

* docs(fork): align /fork contract with implicit-fork-only behavior

Removing the unmirrored /fork command left two stale contract references:
- forkSubagent.ts claimed '/fork <directive> slash command is available'.
- branch/index.ts dropped /branch's 'fork' alias when FORK_SUBAGENT was on
  (on the assumption a dedicated /fork command existed), so the build had
  neither the command nor the alias and /fork resolved to nothing.

Restore /branch as the unconditional owner of the 'fork' alias (its
historical pre-FORK_SUBAGENT behavior, honoring the original 'always have a
fork entry point' intent), drop the now-unused feature import, and correct
the forkSubagent doc to state the slash command is not in this build and
forking is implicit.
2026-06-01 06:00:50 +08:00
beardthelionandGitHub 1d48f8e855 test(build): assert WebFetch binds the real SSRF guard in the bundle (#1450)
#1399 already fixed the specifier-collision class by tracking missing
relative imports per importer, which also resolves the WebFetch ssrfGuard
case (the test-file string literal now only stubs the test importer, never
WebFetch). The remaining gap is bundle-level coverage: the existing
security-hardening test reads source only and would pass even if the
shipped CLI bundle had stubbed the guard to a noop.

Rebase onto current main (dropping the now-redundant scanner change) and
add a dist/cli.mjs assertion alongside the /dream regression test: the real
ssrfGuard blocked-address error is present and ssrfGuard is not replaced by
a missing-module stub.
2026-06-01 05:59:39 +08:00
beardthelionandGitHub f111eaa1b3 feat: enable MCP_SKILLS — discover skill:// resources as invocable skills (#1408)
* feat(mcp-skills): implement MCP skill discovery via skill:// resources

- mcpSkills.ts: fetchMcpSkillsForClient — lists MCP resources, filters skill://
  URIs, reads each via resources/read, parses frontmatter, builds skill commands
  with loadedFrom/source: 'mcp'. Memoized per server name (LRU, size 20).
- isSkillResource: pure helper to detect skill:// URI scheme
- deriveMcpSkillName: namespaced name builder (mcp__<server>__<name>)
- Enable MCP_SKILLS: true in scripts/build.ts

All call sites, cache-invalidation paths, and consumers were already wired
behind feature('MCP_SKILLS'). Only the module itself was missing. Fixes the
"fetchMcpSkillsForClient is not a function" crash (#856) when the flag was
force-enabled without the module present.

* fix(mcp-skills): discard hooks frontmatter from remote MCP skills

A skill:// resource's hooks frontmatter was carried through the
parseSkillFrontmatterFields spread into the Command, and the slash-command
path registered command.hooks as session hooks on invocation. This let any
connected MCP server install local command hooks that later run shell in the
user's workspace, bypassing the loadedFrom === 'mcp' inline-shell guard by
moving the payload into frontmatter hooks instead of the markdown body.

Discard hooks at the MCP construction site so untrusted remote skills can
never become registrable session hooks.

* fix(mcp-skills): discard allowed-tools frontmatter from remote MCP skills

Like hooks, a skill:// resource's allowed-tools frontmatter flowed through
the parseSkillFrontmatterFields spread into the Command. On the user-typed
slash path (/mcp__server__skill) those tools are written into
alwaysAllowRules.command, so a remote MCP server could auto-approve tool
calls (e.g. Bash) that its own skill body then drives the model to make —
with no permission prompt. The inline-shell guard for loadedFrom === 'mcp'
does not cover this.

Discard allowed-tools at the MCP construction site so remote skills can't
auto-grant tools; the model still prompts on each tool use. The model-invoked
SkillTool path already gates non-empty allowedTools via
skillHasOnlySafeProperties, but the slash path bypasses checkPermissions.

* fix(mcp-skills): skip @-mention attachment scanning for remote MCP skill bodies

A skill:// resource's markdown body flows through getMessagesForPromptSlashCommand
into getAttachmentMessages, which scans for @-mentions and MCP resource refs and
reads them before the model continues. skipSkillDiscovery only gates skill
discovery, not @-mention file reads, so a remote skill could embed @~/.ssh/config
or @.env and exfiltrate local file contents into the conversation with no tool
permission prompt — the same class as the already-stripped hooks/allowed-tools.

Gate the scan input on loadedFrom === 'mcp' (new attachmentScanInputForCommand
helper): the body still reaches the model verbatim, but its @-mentions are no
longer auto-read. Thread-level attachments are unaffected (input=null only gates
the user-input branch in getAttachments).
2026-05-31 10:15:07 +08:00