Compare commits

...
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 86db50729e feat(plugins): apply installs, enables and MCP adds made by other processes when idle
The plugin catalog stamp now includes a SHA-256 of the plugin state file, so an
install, enable or disable from another terminal is a catalog change by content
rather than by mtime. The registry refreshes its own stamp after its own state
writes, so a session's /plugin enable never looks like an outside change.

The TUI idle poll also digests the configured MCP config file. When a turn is
not running, an outside plugin or MCP change is applied through the existing
PluginRegistryChanged handler (transcript kept via SyncSession) and the
existing MCP reload; the transcript records the applied change and any added or
removed skills, and the engine's existing new-turn context_update appends the
skill delta and declares the tool-surface change, so the pinned prefix is not
spliced. While a turn runs, the toast stays and the change applies at the next
idle poll. A change to an active provider-declaring plugin says provider
changes apply in a new session.

The MCP digest is tracked in the TUI (not inside PluginCatalogStamp) because
the MCP config path is a per-session setting the plugins crate does not know.

Checks: see the review packet; hosted ci.yml on this SHA.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 18:25:39 -08:00
CodeWhale BotandClaude Opus 5.5 5500fee639 docs(changelog): MCP and LSP clients answer server requests (PR 7319)
Merged as its author after two independent reviews (Opus 5.5, high;
correctness and attacker lenses, both "merge" at 8e8cd373ad) with every
check green on the PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 15:17:22 -08:00
落尘 30952497ca fix(mcp,lsp): answer server requests instead of taking them as replies (#7319)
* fix(mcp,lsp): answer server requests instead of taking them as replies

A JSON-RPC message with a `method` is a request or a notification, never
a reply, but both clients matched any message carrying an `id` against
their own pending requests. Servers number their requests independently,
so a server `ping` (MCP) or `workspace/configuration` (LSP) that shared an
id with an in-flight request resolved that request with no result, the
real reply was then dropped, and the server's request was never answered.

Both clients now skip messages with a `method` when matching replies and
answer server requests: MCP replies to `ping` with an empty result and to
anything else with -32601; LSP replies -32601 through the outbound queue
without blocking the dispatcher.

Tests: cargo test -p codewhale-lsp (65 passed); cargo test -p
codewhale-engine --lib mcp:: (371 passed). The two new tests fail on main.

Fixes #7306

* fix(mcp): keep the in-flight reply when answering a server request fails

The reply to a server request (`ping`, or -32601 for anything else) was
sent with `self.send(reply).await?`. A failed send failed the
`tools/call` it interrupted, even though the server's real answer was
still on its way, and marked the connection disconnected. Over HTTP it
was worse: a 404/405/406/415/501 on that POST made the transport fall
back to legacy SSE, dropping the Streamable HTTP messages still queued
for the request in flight.

The reply now goes through a new `McpTransport::send_reply`, which
defaults to `send`; HttpTransport overrides it to report a refusal
without changing mode. A failure is logged at debug level and not
propagated, and the connection state is left alone.

Tests: cargo test -p codewhale-engine --lib mcp:: (373 passed); clippy
--all-targets -D warnings clean. The two new tests fail without this
change.
2026-11-13 07:16:25 +08:00
18 changed files with 523 additions and 28 deletions

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Session names after a crash, and keep-forever snapshot retention:** a session name saved right after a crash that cut off the previous session-index line is no longer lost, because the new entry now starts on its own line; and a very large `[snapshots] max_age_days` (such as `9223372036854775807` for "keep forever") no longer wraps around and deletes every undo snapshot at session start. Thanks [@luochen211](https://github.com/luochen211) (PR 7316).
- **Blank environment variables:** exporting a variable empty (for example `CODEWHALE_MODEL=` from a compose `${VAR:-}` template) no longer hides its legacy alias. `DEEPSEEK_MODEL`, `DEEPSEEK_BASE_URL`, `KIMI_BASE_URL`, `DEEPSEEK_FORCE_HTTP1`, `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` and the other provider alias variables now apply as they do when the primary is unset. Thanks [@luochen211](https://github.com/luochen211) (PR 7315).
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (PR 7293).
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
+44
View File
@@ -1669,6 +1669,10 @@ pub trait McpTransport: Send + Sync {
async fn recv(&mut self) -> Result<Vec<u8>>;
async fn send_reply(&mut self, msg: Vec<u8>) -> Result<()> {
self.send(msg).await
}
/// Record the protocol revision negotiated at `initialize`. Only the
/// Streamable HTTP transport uses it (the `MCP-Protocol-Version` header on
/// subsequent requests); stdio and legacy SSE have no header channel, so
@@ -3125,6 +3129,30 @@ impl McpConnection {
result
}
async fn answer_server_request(&mut self, reply: &serde_json::Value) {
let result = match serde_json::to_vec(reply) {
Ok(bytes) => {
let cancel_token = self.cancel_token.clone();
tokio::select! {
biased;
_ = cancel_token.cancelled() => {
Err(anyhow::anyhow!("MCP connection was cancelled"))
}
result = self.transport.send_reply(bytes) => result,
}
}
Err(err) => Err(err.into()),
};
if let Err(err) = result {
tracing::debug!(
target: "mcp",
server = %self.name,
error = %err,
"could not answer an MCP server request"
);
}
}
/// Handshake and discovery receive: each frame wait is bounded by the read
/// knob, and a server that stays silent past it is treated as dead.
async fn recv(&mut self, expected_id: String) -> Result<serde_json::Value> {
@@ -3202,6 +3230,22 @@ impl McpConnection {
{
self.catalog_stale = true;
}
if let Some(method) = value.get("method") {
if let Some(id) = value.get("id") {
let method = method.as_str().unwrap_or_default();
let reply = if method == "ping" {
serde_json::json!({"jsonrpc": "2.0", "id": id, "result": {}})
} else {
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"error": {"code": -32601, "message": format!("Method not found: {method}")}
})
};
self.answer_server_request(&reply).await;
}
continue;
}
if response_id_matches(value.get("id"), &expected_id) {
// Marks the connection stale so it is rebuilt, but this is a
// reply to the request, so it never qualifies for a replay.
+15
View File
@@ -177,6 +177,21 @@ impl McpTransport for HttpTransport {
}
}
async fn send_reply(&mut self, msg: Vec<u8>) -> Result<()> {
match &mut self.mode {
HttpTransportMode::Streamable(transport) => {
transport.send(msg).await.map_err(|err| match err {
StreamableSendError::Incompatible(detail)
| StreamableSendError::StaleSession(detail) => anyhow::anyhow!(
"MCP Streamable HTTP refused a reply to a server request: {detail}"
),
StreamableSendError::Other(err) => err,
})
}
HttpTransportMode::Sse(transport) => transport.send(msg).await,
}
}
async fn recv(&mut self) -> Result<Vec<u8>> {
match &mut self.mode {
HttpTransportMode::Streamable(transport) => transport.recv().await,
+128
View File
@@ -3314,6 +3314,80 @@ async fn call_method_skips_notifications_and_unmatched_responses() {
assert_eq!(sent[0]["method"], "tools/call");
}
#[tokio::test]
async fn call_method_answers_server_requests_instead_of_taking_them_as_the_reply() {
let sent = Arc::new(Mutex::new(Vec::new()));
let transport = ScriptedValueTransport {
sent: Arc::clone(&sent),
responses: VecDeque::from([
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": "1", "method": "roots/list"})),
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
]),
};
let mut conn = test_connection(Box::new(transport));
let result = conn
.call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
.await
.unwrap();
assert_eq!(result, serde_json::json!({"ok": true}));
let sent = sent.lock().unwrap();
assert_eq!(sent.len(), 3);
assert_eq!(
sent[1],
serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {}})
);
assert_eq!(sent[2]["id"], "1");
assert_eq!(sent[2]["error"]["code"], -32601);
}
struct ReplyRefusingTransport {
sent: Arc<Mutex<Vec<serde_json::Value>>>,
responses: VecDeque<Vec<u8>>,
}
#[async_trait::async_trait]
impl McpTransport for ReplyRefusingTransport {
async fn send(&mut self, msg: Vec<u8>) -> Result<()> {
let msg: serde_json::Value = serde_json::from_slice(&msg)?;
anyhow::ensure!(msg.get("method").is_some(), "status=405 Method Not Allowed");
self.sent.lock().unwrap().push(msg);
Ok(())
}
async fn recv(&mut self) -> Result<Vec<u8>> {
self.responses
.pop_front()
.context("scripted transport exhausted")
}
}
#[tokio::test]
async fn call_method_keeps_its_reply_when_answering_a_server_request_fails() {
let sent = Arc::new(Mutex::new(Vec::new()));
let transport = ReplyRefusingTransport {
sent: Arc::clone(&sent),
responses: VecDeque::from([
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
]),
};
let mut conn = test_connection(Box::new(transport));
let result = conn
.call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
.await
.unwrap();
assert_eq!(result, serde_json::json!({"ok": true}));
assert_eq!(conn.state, ConnectionState::Ready);
let sent = sent.lock().unwrap();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0]["method"], "tools/call");
}
#[tokio::test]
async fn call_method_invalid_json_includes_server_output_preview() {
let sent = Arc::new(Mutex::new(Vec::new()));
@@ -6770,6 +6844,60 @@ async fn custom_headers_applied_to_get_preflight() {
);
}
#[tokio::test]
async fn refused_reply_to_a_server_request_does_not_fall_back_to_sse() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let _env = crate::test_support::lock_test_env();
let _lock = lock_mcp_loopback_tests().await;
let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*,127.0.0.1");
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let requests = Arc::new(Mutex::new(Vec::new()));
let requests_srv = Arc::clone(&requests);
let server = tokio::spawn(async move {
loop {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let request_line = String::from_utf8_lossy(&buf[..n])
.lines()
.next()
.unwrap_or_default()
.to_string();
requests_srv.lock().unwrap().push(request_line);
socket
.write_all(
b"HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
)
.await
.unwrap();
}
});
let url = format!("http://{addr}/mcp");
let mut transport = HttpTransport::new(
test_mcp_http_client(&url),
url,
tokio_util::sync::CancellationToken::new(),
Duration::from_secs(2),
);
let result = transport
.send_reply(json_frame(
serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {}}),
))
.await;
server.abort();
assert!(result.is_err(), "a refused reply reports the refusal");
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 1, "requests seen: {requests:?}");
assert!(requests[0].starts_with("POST "), "{requests:?}");
}
// === add_runtime_server_config conflict tests ===
#[test]
+51 -6
View File
@@ -255,11 +255,12 @@ impl StdioLspTransport {
let child = Arc::new(AsyncMutex::new(Some(child)));
let dispatcher_child = child.clone();
let dispatcher_pending = pending.clone();
let dispatcher_outbound = tx_outbound.clone();
let dispatcher_task = spawn_supervised(
"lsp-dispatcher",
std::panic::Location::caller(),
async move {
dispatcher_task(rx_inbound, tx_diag, dispatcher_pending).await;
dispatcher_task(rx_inbound, tx_diag, dispatcher_outbound, dispatcher_pending).await;
// EOF, malformed frames, or an overflowing diagnostics queue
// terminate the producer too, so its pipes cannot stay stuck.
if let Some(mut child) = dispatcher_child.lock().await.take() {
@@ -536,15 +537,19 @@ impl LspTransport for StdioLspTransport {
/// Send a JSON value as one Content-Length-framed JSON-RPC message.
async fn send_message(tx: &mpsc::Sender<Vec<u8>>, value: &Value) -> Result<()> {
tx.send(frame_message(value)?)
.await
.map_err(|_| anyhow!("LSP outbound channel closed"))?;
Ok(())
}
fn frame_message(value: &Value) -> Result<Vec<u8>> {
let body = serde_json::to_vec(value).context("serialize LSP message")?;
let header = format!("Content-Length: {}\r\n\r\n", body.len());
let mut frame = Vec::with_capacity(header.len() + body.len());
frame.extend_from_slice(header.as_bytes());
frame.extend_from_slice(&body);
tx.send(frame)
.await
.map_err(|_| anyhow!("LSP outbound channel closed"))?;
Ok(())
Ok(frame)
}
/// Background task that drains the outbound queue and writes each frame to
@@ -638,6 +643,7 @@ fn parse_header(buf: &[u8]) -> Result<Option<(usize, usize)>> {
async fn dispatcher_task(
mut rx: mpsc::Receiver<Value>,
tx_diag: mpsc::Sender<DiagnosticMessage>,
tx_outbound: mpsc::Sender<Vec<u8>>,
pending: Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>>,
) {
while let Some(value) = rx.recv().await {
@@ -654,6 +660,19 @@ async fn dispatcher_task(
}
continue;
}
if let Some(method) = method {
if let Some(id) = value.get("id") {
let reply = json!({
"jsonrpc": "2.0",
"id": id,
"error": {"code": -32601, "message": format!("unsupported method {method}")},
});
if let Ok(frame) = frame_message(&reply) {
let _ = tx_outbound.try_send(frame);
}
}
continue;
}
// Replies have an `id` and a `result` or `error`.
if let Some(id) = value.get("id").and_then(|v| v.as_i64()) {
let mut map = pending.lock().await;
@@ -780,6 +799,31 @@ pub(super) mod tests {
assert_eq!(diags[0].message, "missing semicolon");
}
#[tokio::test]
async fn server_request_sharing_a_pending_id_is_answered_not_taken_as_the_reply() {
let (tx_in, rx_in) = mpsc::channel(4);
let (tx_out, mut rx_out) = mpsc::channel::<Vec<u8>>(4);
let (tx_diag, _rx_diag) = mpsc::channel(4);
let pending = Arc::new(AsyncMutex::new(HashMap::new()));
let (reply_tx, reply_rx) = oneshot::channel();
pending.lock().await.insert(2, reply_tx);
let dispatcher = tokio::spawn(dispatcher_task(rx_in, tx_diag, tx_out, pending));
tx_in
.send(json!({"jsonrpc":"2.0","id":2,"method":"workspace/configuration","params":{"items":[]}}))
.await
.unwrap();
tx_in
.send(json!({"jsonrpc":"2.0","id":2,"result":"definition"}))
.await
.unwrap();
assert_eq!(reply_rx.await.unwrap()["result"], "definition");
let answer = next_document(&mut rx_out).await;
assert_eq!(answer["id"], 2);
assert_eq!(answer["error"]["code"], -32601);
drop(tx_in);
dispatcher.await.unwrap();
}
#[test]
fn round_trips_uri_path() {
let path = fixture_path("example/foo.rs");
@@ -1309,11 +1353,12 @@ while True:
let (mut producer, reader) = tokio::io::duplex(MAX_LSP_HEADER_BYTES * 2);
let (tx, rx) = mpsc::channel(8);
let (diag, _diagnostics) = mpsc::channel(8);
let (outbound, _outbound_rx) = mpsc::channel(8);
let pending = Arc::new(AsyncMutex::new(HashMap::new()));
let (reply, receiver) = oneshot::channel();
pending.lock().await.insert(1, reply);
let read_task = tokio::spawn(reader_task(reader, tx));
let dispatch = tokio::spawn(dispatcher_task(rx, diag, pending.clone()));
let dispatch = tokio::spawn(dispatcher_task(rx, diag, outbound, pending.clone()));
producer.write_all(&frame).await.unwrap();
assert!(
timeout(Duration::from_secs(1), receiver)
+1 -1
View File
@@ -182,7 +182,7 @@ impl PluginDiscoveryContext {
super::discovery::default_workspace_plugins_dir(workspace),
];
roots.extend(self.builtin_plugin_dirs.iter().cloned());
super::discovery::PluginCatalogStamp::capture(roots)
super::discovery::PluginCatalogStamp::capture(roots, &self.state_path)
}
}
+34 -7
View File
@@ -27,20 +27,40 @@ pub struct DiscoveryConfig {
pub state_path: PathBuf,
}
/// Cheap on-disk catalog fingerprint: plugin-bundle directories only.
/// Cheap on-disk catalog fingerprint: plugin-bundle directories plus the
/// content of the plugin state file.
///
/// Used to nudge `/plugin reload` when a bundle appears, disappears, or is
/// rewritten without consulting process environment or auto-applying trust.
/// `state.json` writes are ignored so enable/trust does not look like a
/// catalog change.
/// Used to apply or nudge a reload when a bundle appears, disappears, or is
/// rewritten, or when another process installs, enables or disables a plugin.
/// The state file is compared by content digest, never by mtime, so a rewrite
/// with identical bytes is not a change and a same-second rewrite is.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PluginCatalogStamp {
entries: BTreeMap<PathBuf, Option<SystemTime>>,
state: Option<[u8; 32]>,
}
const MAX_STAMPED_FILE_BYTES: u64 = 8 * 1024 * 1024;
/// SHA-256 of a small file's bytes, or `None` when it is absent, unreadable or
/// larger than the stamp bound.
#[must_use]
pub fn file_content_digest(path: &Path) -> Option<[u8; 32]> {
use std::io::Read;
let file = fs::File::open(path).ok()?;
let mut bytes = Vec::new();
file.take(MAX_STAMPED_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.ok()?;
if bytes.len() as u64 > MAX_STAMPED_FILE_BYTES {
return None;
}
Some(Sha256::digest(&bytes).into())
}
impl PluginCatalogStamp {
#[must_use]
pub fn capture(roots: impl IntoIterator<Item = PathBuf>) -> Self {
pub fn capture(roots: impl IntoIterator<Item = PathBuf>, state_path: &Path) -> Self {
let mut entries = BTreeMap::new();
for root in roots {
let Ok(read) = fs::read_dir(&root) else {
@@ -57,7 +77,14 @@ impl PluginCatalogStamp {
entries.insert(path, metadata.modified().ok());
}
}
Self { entries }
Self {
entries,
state: file_content_digest(state_path),
}
}
pub fn refresh_state(&mut self, state_path: &Path) {
self.state = file_content_digest(state_path);
}
}
+31 -1
View File
@@ -27,11 +27,41 @@ pub mod test_fixture;
#[cfg(test)]
mod tests;
use std::collections::BTreeMap;
pub use context::{HostEnvironment, PluginDiscoveryContext};
pub use discovery::PluginCatalogStamp;
pub use registry::PluginRegistry;
pub const PLUGIN_RELOAD_NUDGE: &str = "Plugins changed on disk. Run /plugin reload to apply.";
pub const PLUGIN_RELOAD_NUDGE: &str =
"Plugins changed on disk. They apply when the session is idle, or run /plugin reload.";
pub const PLUGIN_PROVIDER_NEW_SESSION_NOTICE: &str =
"Plugin provider changes apply in a new session.";
fn active_provider_declarations(
registry: &PluginRegistry,
) -> BTreeMap<String, (String, Vec<String>)> {
registry
.active_plugins()
.into_iter()
.filter(|plugin| !plugin.manifest.providers.is_empty())
.map(|plugin| {
(
plugin.id.as_str().to_string(),
(
plugin.content_hash.clone(),
plugin.manifest.providers.keys().cloned().collect(),
),
)
})
.collect()
}
#[must_use]
pub fn provider_declarations_changed(before: &PluginRegistry, after: &PluginRegistry) -> bool {
active_provider_declarations(before) != active_provider_declarations(after)
}
/// Returns the reload nudge once per distinct on-disk catalog stamp.
#[must_use]
+1
View File
@@ -1326,6 +1326,7 @@ impl PluginRegistry {
let mut next = load_state_unlocked(path)?;
mutate(&mut next)?;
save_state(path, &next)?;
self.catalog_stamp.refresh_state(path);
self.state = next;
// Runtime selection rechecks the persisted review through its own
// read lock. Publish the completed state transaction before deriving
+20
View File
@@ -35,6 +35,26 @@ fn write_named_plugin(config: &DiscoveryConfig, name: &str, extra: &str) -> Path
plugin
}
#[test]
fn catalog_stamp_changes_when_only_state_bytes_change() {
let tmp = tempfile::tempdir().unwrap();
let config = config(tmp.path());
write_plugin(&config, "");
let registry = discover_with_config(&config);
assert!(!registry.on_disk_catalog_changed());
std::fs::create_dir_all(config.state_path.parent().unwrap()).unwrap();
std::fs::write(&config.state_path, "{\"version\":1}").unwrap();
assert!(registry.on_disk_catalog_changed());
let reloaded = registry.rediscover_for_workspace(&config.workspace);
assert!(!reloaded.on_disk_catalog_changed());
std::fs::write(&config.state_path, "{\"version\":1}").unwrap();
assert!(!reloaded.on_disk_catalog_changed());
std::fs::write(&config.state_path, "{\"version\":2}").unwrap();
assert!(reloaded.on_disk_catalog_changed());
}
#[test]
fn on_disk_catalog_change_nudges_reload_once_until_rediscover() {
let tmp = tempfile::tempdir().unwrap();
+1
View File
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Session names after a crash, and keep-forever snapshot retention:** a session name saved right after a crash that cut off the previous session-index line is no longer lost, because the new entry now starts on its own line; and a very large `[snapshots] max_age_days` (such as `9223372036854775807` for "keep forever") no longer wraps around and deletes every undo snapshot at session start. Thanks [@luochen211](https://github.com/luochen211) (PR 7316).
- **Blank environment variables:** exporting a variable empty (for example `CODEWHALE_MODEL=` from a compose `${VAR:-}` template) no longer hides its legacy alias. `DEEPSEEK_MODEL`, `DEEPSEEK_BASE_URL`, `KIMI_BASE_URL`, `DEEPSEEK_FORCE_HTTP1`, `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` and the other provider alias variables now apply as they do when the primary is unset. Thanks [@luochen211](https://github.com/luochen211) (PR 7315).
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (PR 7293).
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
+1
View File
@@ -1736,6 +1736,7 @@ pub struct App {
pub plugin_reload_nudge_stamp: Option<codewhale_plugins::PluginCatalogStamp>,
/// Last idle catalog fingerprint poll, so disk changes can surface between turns.
pub last_plugin_catalog_poll: Option<Instant>,
pub mcp_config_watch: Option<crate::tui::plugin_suggestions::McpConfigWatch>,
/// Live composer plugin CTA (debounce + one match, never auto-install).
pub plugin_cta: crate::tui::plugin_suggestions::PluginCtaState,
pub model: String,
+1
View File
@@ -858,6 +858,7 @@ impl App {
context_pressure_warning_dismissed: None,
plugin_reload_nudge_stamp: None,
last_plugin_catalog_poll: None,
mcp_config_watch: None,
plugin_cta: crate::tui::plugin_suggestions::PluginCtaState::from_settings(&settings),
model,
provider_models,
+107 -11
View File
@@ -31,6 +31,22 @@ use codewhale_plugins::recommend::{
const CATALOG_POLL_INTERVAL: Duration = Duration::from_secs(2);
pub const MCP_CONFIG_NUDGE: &str =
"MCP servers changed on disk. They apply when the session is idle, or run /mcp reload.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExternalCatalogChange {
pub plugins: bool,
pub mcp: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpConfigWatch {
pub digest: Option<[u8; 32]>,
pub generation: u64,
pub notified: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginCtaPhase {
Hidden,
@@ -124,24 +140,63 @@ impl App {
true
}
/// Cheap idle poll so on-disk plugin changes can surface between turns,
/// not only on send. Fingerprints directories; never auto-reloads.
pub fn maybe_poll_plugin_catalog_idle(&mut self) {
pub fn poll_external_catalog_changes(
&mut self,
engine_busy: bool,
) -> Option<ExternalCatalogChange> {
let now = Instant::now();
if self
.last_plugin_catalog_poll
.is_some_and(|seen| now.duration_since(seen) < CATALOG_POLL_INTERVAL)
{
return;
return None;
}
self.last_plugin_catalog_poll = Some(now);
if let Some(message) = codewhale_plugins::plugin_reload_nudge(
self.plugin_registry.as_ref(),
&mut self.plugin_reload_nudge_stamp,
) {
self.push_status_toast(message, StatusToastLevel::Warning, Some(8_000));
self.needs_redraw = true;
let digest = codewhale_plugins::discovery::file_content_digest(&self.mcp_config_path);
let generation = self.mcp_snapshot_generation;
let watch = self.mcp_config_watch.get_or_insert(McpConfigWatch {
digest,
generation,
notified: false,
});
if watch.generation != generation {
*watch = McpConfigWatch {
digest,
generation,
notified: false,
};
}
let mcp = watch.digest != digest;
let mcp_unannounced = mcp && !std::mem::replace(&mut watch.notified, true);
let plugins = self.plugin_registry.on_disk_catalog_changed();
if !plugins && !mcp {
return None;
}
if engine_busy || self.pending_fresh_session.is_some() {
if plugins
&& let Some(message) = codewhale_plugins::plugin_reload_nudge(
self.plugin_registry.as_ref(),
&mut self.plugin_reload_nudge_stamp,
)
{
self.push_status_toast(message, StatusToastLevel::Warning, Some(8_000));
self.needs_redraw = true;
}
if mcp_unannounced {
self.push_status_toast(MCP_CONFIG_NUDGE, StatusToastLevel::Warning, Some(8_000));
self.needs_redraw = true;
}
return None;
}
Some(ExternalCatalogChange { plugins, mcp })
}
pub fn mark_external_catalog_applied(&mut self) {
self.mcp_config_watch = Some(McpConfigWatch {
digest: codewhale_plugins::discovery::file_content_digest(&self.mcp_config_path),
generation: self.mcp_snapshot_generation,
notified: false,
});
}
#[must_use]
@@ -417,12 +472,53 @@ mod tests {
);
}
#[test]
fn external_state_and_mcp_writes_apply_when_idle_and_toast_while_busy() {
let _lock = crate::test_support::lock_test_env();
let (mut app, _root, _home) = app_with_supabase_plugin();
assert_eq!(app.poll_external_catalog_changes(false), None);
let state_path = app.plugin_registry.state_path().unwrap().to_path_buf();
fs::create_dir_all(state_path.parent().unwrap()).unwrap();
fs::write(&state_path, "{\"version\":1,\"plugins\":{}}").unwrap();
fs::write(&app.mcp_config_path, "{\"servers\":{}}").unwrap();
app.last_plugin_catalog_poll = None;
assert_eq!(app.poll_external_catalog_changes(true), None);
let toasts = app
.status_toasts
.iter()
.map(|toast| toast.text.to_string())
.collect::<Vec<_>>();
assert!(toasts.iter().any(|text| text.contains("Plugins changed")));
assert!(
toasts
.iter()
.any(|text| text.contains("MCP servers changed"))
);
app.last_plugin_catalog_poll = None;
assert_eq!(
app.poll_external_catalog_changes(false),
Some(ExternalCatalogChange {
plugins: true,
mcp: true
})
);
app.mark_external_catalog_applied();
app.plugin_registry = app
.plugin_registry
.rediscover_for_workspace(&app.workspace.clone());
app.last_plugin_catalog_poll = None;
assert_eq!(app.poll_external_catalog_changes(false), None);
}
#[test]
fn typing_a_matching_draft_never_shows_a_row() {
let _lock = crate::test_support::lock_test_env();
let (mut app, _root, _home) = app_with_supabase_plugin();
app.input = "add supabase auth to login".to_string();
app.maybe_poll_plugin_catalog_idle();
let _ = app.poll_external_catalog_changes(false);
assert!(!app.plugin_cta.phase.is_visible());
assert_eq!(app.plugin_cta_row_height(), 0);
}
+2
View File
@@ -981,7 +981,9 @@ pub(crate) use compaction_flow::*;
pub(crate) use provider_setup::*;
mod dispatch;
mod dispatch_prepare;
mod external_catalog;
pub(crate) use dispatch_prepare::*;
pub(crate) use external_catalog::*;
pub(crate) mod fatal_signal_guard;
// #6169: runtime half of the foreground-ownership contract — restore on stop,
// rebuild on continue. Sits next to the fatal guard because both write the same
+11 -1
View File
@@ -2640,7 +2640,17 @@ pub(crate) async fn run_event_loop(
// #1830/#2317: service any already-arrived terminal keys before a
// potentially long engine batch so composer/modal input stays live.
collect_pending_terminal_events(&terminal_input, &mut pending_terminal_events)?;
app.maybe_poll_plugin_catalog_idle();
if let Some(change) = app.poll_external_catalog_changes(mcp_engine_busy(app)) {
apply_external_catalog_change(
terminal,
app,
&mut engine_handle,
&task_manager,
config,
change,
)
.await?;
}
if drain_remote_control_events(app, config, &engine_handle).await? {
app.needs_redraw = true;
+73
View File
@@ -0,0 +1,73 @@
use super::*;
use crate::tui::plugin_suggestions::ExternalCatalogChange;
fn skill_delta(before: &[(String, String)], after: &[(String, String)]) -> String {
let names = |skills: &[(String, String)]| {
skills
.iter()
.map(|(name, _)| name.clone())
.collect::<std::collections::BTreeSet<_>>()
};
let (before, after) = (names(before), names(after));
let added = after.difference(&before).cloned().collect::<Vec<_>>();
let removed = before.difference(&after).cloned().collect::<Vec<_>>();
let mut parts = Vec::new();
if !added.is_empty() {
parts.push(format!("skills added: {}", added.join(", ")));
}
if !removed.is_empty() {
parts.push(format!("skills removed: {}", removed.join(", ")));
}
parts.join("; ")
}
pub(crate) async fn apply_external_catalog_change(
terminal: &mut AppTerminal,
app: &mut App,
engine_handle: &mut EngineHandle,
task_manager: &SharedTaskManager,
config: &mut Config,
change: ExternalCatalogChange,
) -> Result<()> {
app.mark_external_catalog_applied();
if change.plugins {
let before = app.plugin_registry.clone();
let skills_before = app.cached_skills.clone();
let workspace = app.workspace.clone();
app.plugin_registry = before.rediscover_for_workspace(&workspace);
app.refresh_skill_cache();
let mut message = String::from("Plugins changed outside this session and were applied");
let delta = skill_delta(&skills_before, &app.cached_skills);
if !delta.is_empty() {
message.push_str(&format!(" ({delta})"));
}
message.push('.');
if codewhale_plugins::provider_declarations_changed(
before.as_ref(),
app.plugin_registry.as_ref(),
) {
message.push(' ');
message.push_str(codewhale_plugins::PLUGIN_PROVIDER_NEW_SESSION_NOTICE);
}
let result = commands::CommandResult::with_message_and_action(
message,
AppAction::PluginRegistryChanged,
);
apply_command_result(terminal, app, engine_handle, task_manager, config, result).await?;
}
if change.mcp {
let extensions_was_open = app.view_stack.extensions_is_top();
handle_mcp_ui_action(
app,
engine_handle,
config,
crate::tui::app::McpUiAction::Reload,
)
.await;
if !extensions_was_open && app.view_stack.extensions_is_top() {
app.view_stack.pop();
}
}
app.needs_redraw = true;
Ok(())
}
+1 -1
View File
@@ -740,7 +740,7 @@ fn mcp_scoped_config_path(
/// Whether a turn (or its compaction work) owns the engine. The engine
/// services ops only between turns, so an op sent now waits for that turn.
fn mcp_engine_busy(app: &App) -> bool {
pub(super) fn mcp_engine_busy(app: &App) -> bool {
app.is_loading
|| app.dispatch_in_flight
|| matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))