From 4a9be9d3b66f9ea687e25b5566f8b45e323d1176 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:42:54 -0700 Subject: [PATCH 01/21] refactor(platform): one Freshell context env builder for terminals and agents Both build_terminal_base_env copies (freshell-ws terminal.rs and freshell-freshagent terminal_tabs.rs) now delegate to freshell_platform::freshell_context_env, which also serves the freshclaude/kilroy sidecar next. Pinned by the existing base_env tests and the terminal_tabs env tests; the js_number_string test moves with the function. Co-Authored-By: Claude Opus 5.5 --- .../freshell-freshagent/src/terminal_tabs.rs | 48 +---- .../src/freshell_context_env.rs | 165 ++++++++++++++++++ crates/freshell-platform/src/lib.rs | 1 + crates/freshell-ws/src/terminal.rs | 58 +----- 4 files changed, 178 insertions(+), 94 deletions(-) create mode 100644 crates/freshell-platform/src/freshell_context_env.rs diff --git a/crates/freshell-freshagent/src/terminal_tabs.rs b/crates/freshell-freshagent/src/terminal_tabs.rs index ceb3e7854..25ab07a72 100644 --- a/crates/freshell-freshagent/src/terminal_tabs.rs +++ b/crates/freshell-freshagent/src/terminal_tabs.rs @@ -390,29 +390,12 @@ fn build_terminal_base_env( tab_id: Option<&str>, pane_id: Option<&str>, ) -> BTreeMap { - let mut out = BTreeMap::new(); - out.insert("FRESHELL".to_string(), "1".to_string()); - let port_raw = env - .get("PORT") - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| "3001".to_string()); - let url = env - .get("FRESHELL_URL") - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| format!("http://localhost:{}", js_number_string(&port_raw))); - out.insert("FRESHELL_URL".to_string(), url); - out.insert( - "FRESHELL_TOKEN".to_string(), - env.get("AUTH_TOKEN").unwrap_or_default(), - ); - out.insert("FRESHELL_TERMINAL_ID".to_string(), terminal_id.to_string()); - if let Some(t) = tab_id.filter(|s| !s.is_empty()) { - out.insert("FRESHELL_TAB_ID".to_string(), t.to_string()); - } - if let Some(p) = pane_id.filter(|s| !s.is_empty()) { - out.insert("FRESHELL_PANE_ID".to_string(), p.to_string()); - } - out + freshell_platform::freshell_context_env::freshell_context_env( + &freshell_platform::freshell_context_env::FreshellEndpoint::from_env(env), + Some(terminal_id), + tab_id, + pane_id, + ) } /// One value-safe managed Codex rendering for this terminal tab's TUI and a @@ -463,25 +446,6 @@ fn build_codex_managed_launch_setup( }) } -/// JS `String(Number(s))` for the `PORT` template slot (mirrored from -/// `crates/freshell-ws/src/terminal.rs:1313`). -fn js_number_string(s: &str) -> String { - let t = s.trim(); - if t.is_empty() { - return "0".to_string(); - } - match t.parse::() { - Ok(n) if n.is_finite() => { - if n.fract() == 0.0 && n.abs() < 1e15 { - format!("{}", n as i64) - } else { - format!("{n}") - } - } - _ => "NaN".to_string(), - } -} - /// `wrapTerminalSpawnError` (`terminal-registry.ts:450-481`, mirrored from /// `crates/freshell-ws/src/terminal.rs:1334`): the user-facing spawn-failure /// message. diff --git a/crates/freshell-platform/src/freshell_context_env.rs b/crates/freshell-platform/src/freshell_context_env.rs new file mode 100644 index 000000000..68b22acd9 --- /dev/null +++ b/crates/freshell-platform/src/freshell_context_env.rs @@ -0,0 +1,165 @@ +//! The Freshell context a child process gets so it can reach this server +//! (the Freshell CLI and MCP client read it): `FRESHELL=1`, `FRESHELL_URL`, +//! `FRESHELL_TOKEN`, and the pane it runs for (`FRESHELL_TERMINAL_ID`, +//! `FRESHELL_TAB_ID`, `FRESHELL_PANE_ID`). +//! +//! One builder for every lane that hands it out: the terminal lanes +//! (`buildTerminalBaseEnv`, `terminal-registry.ts:1529-1542`) and the +//! freshclaude/kilroy sidecar, so the names and the omission rules cannot drift. + +use std::collections::BTreeMap; + +use crate::Env; + +/// The context names this module writes, in no particular order. +pub const FRESHELL: &str = "FRESHELL"; +pub const FRESHELL_URL: &str = "FRESHELL_URL"; +pub const FRESHELL_TOKEN: &str = "FRESHELL_TOKEN"; +pub const FRESHELL_TERMINAL_ID: &str = "FRESHELL_TERMINAL_ID"; +pub const FRESHELL_TAB_ID: &str = "FRESHELL_TAB_ID"; +pub const FRESHELL_PANE_ID: &str = "FRESHELL_PANE_ID"; + +/// The pane-scoped names: a child launched for one pane must never inherit +/// another pane's values (for example from the environment of a server that +/// itself runs inside a Freshell terminal). +pub const FRESHELL_PANE_CONTEXT_ENV_VARS: [&str; 3] = + [FRESHELL_TERMINAL_ID, FRESHELL_TAB_ID, FRESHELL_PANE_ID]; + +/// Where this server answers its REST API, and the token that authorizes it. +#[derive(Clone, PartialEq, Eq)] +pub struct FreshellEndpoint { + pub url: String, + pub token: String, +} + +impl std::fmt::Debug for FreshellEndpoint { + // The token never reaches a log line. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FreshellEndpoint") + .field("url", &self.url) + .field("token", &"") + .finish() + } +} + +impl FreshellEndpoint { + /// The endpoint of a server listening on `port` on this host. + pub fn local(port: u16, token: impl Into) -> Self { + Self { + url: format!("http://localhost:{port}"), + token: token.into(), + } + } + + /// The terminal lanes' endpoint, from the process environment + /// (`terminal-registry.ts:1529-1542`): `FRESHELL_URL` when set, else + /// `http://localhost:` (`PORT` defaults to 3001, coerced the way the + /// reference's `Number(...)` does), and `AUTH_TOKEN` (empty when unset). + pub fn from_env(env: &dyn Env) -> Self { + // `const port = Number(process.env.PORT || 3001)` (truthy: '' → 3001). + let port_raw = env + .get("PORT") + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| "3001".to_string()); + let url = env + .get(FRESHELL_URL) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| format!("http://localhost:{}", js_number_string(&port_raw))); + Self { + url, + token: env.get("AUTH_TOKEN").unwrap_or_default(), + } + } +} + +/// The context environment for a child: `FRESHELL=1`, the endpoint, and each +/// of `terminal_id`, `tab_id`, `pane_id` that is present and non-empty (an +/// absent or empty id leaves its key out, `...(tabId ? {...} : {})`). +pub fn freshell_context_env( + endpoint: &FreshellEndpoint, + terminal_id: Option<&str>, + tab_id: Option<&str>, + pane_id: Option<&str>, +) -> BTreeMap { + let mut out = BTreeMap::new(); + out.insert(FRESHELL.to_string(), "1".to_string()); + out.insert(FRESHELL_URL.to_string(), endpoint.url.clone()); + out.insert(FRESHELL_TOKEN.to_string(), endpoint.token.clone()); + for (name, value) in [ + (FRESHELL_TERMINAL_ID, terminal_id), + (FRESHELL_TAB_ID, tab_id), + (FRESHELL_PANE_ID, pane_id), + ] { + if let Some(value) = value.filter(|v| !v.is_empty()) { + out.insert(name.to_string(), value.to_string()); + } + } + out +} + +/// JS `String(Number(s))` for the `PORT` template slot: every real deployment is +/// a plain integer; whitespace-only → `0`, unparseable → `NaN` (faithful to the +/// reference's `Number(...)` coercion in the template literal). +pub fn js_number_string(s: &str) -> String { + let t = s.trim(); + if t.is_empty() { + return "0".to_string(); + } + match t.parse::() { + Ok(n) if n.is_finite() => { + if n.fract() == 0.0 && n.abs() < 1e15 { + format!("{}", n as i64) + } else { + format!("{n}") + } + } + _ => "NaN".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn js_number_string_coercion() { + assert_eq!(js_number_string("17872"), "17872"); + assert_eq!(js_number_string(" 17872 "), "17872"); + assert_eq!(js_number_string("abc"), "NaN"); + assert_eq!(js_number_string(" "), "0"); + } + + #[test] + fn a_local_endpoint_names_this_hosts_port() { + let endpoint = FreshellEndpoint::local(3344, "tok"); + assert_eq!(endpoint.url, "http://localhost:3344"); + assert_eq!(endpoint.token, "tok"); + } + + #[test] + fn the_debug_form_never_shows_the_token() { + let shown = format!("{:?}", FreshellEndpoint::local(1, "secret-token")); + assert!(!shown.contains("secret-token"), "{shown}"); + } + + #[test] + fn a_context_without_a_terminal_carries_the_endpoint_and_the_pane() { + let out = freshell_context_env( + &FreshellEndpoint::local(9, "tok"), + None, + Some("tab-1"), + Some("pane-1"), + ); + let expected: BTreeMap = [ + ("FRESHELL", "1"), + ("FRESHELL_URL", "http://localhost:9"), + ("FRESHELL_TOKEN", "tok"), + ("FRESHELL_TAB_ID", "tab-1"), + ("FRESHELL_PANE_ID", "pane-1"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + assert_eq!(out, expected); + } +} diff --git a/crates/freshell-platform/src/lib.rs b/crates/freshell-platform/src/lib.rs index 937ce2cac..bd7f8440d 100644 --- a/crates/freshell-platform/src/lib.rs +++ b/crates/freshell-platform/src/lib.rs @@ -57,6 +57,7 @@ pub mod cli_launch; pub mod clock; pub mod codex_inputs; pub mod detect; +pub mod freshell_context_env; pub mod git_meta; pub mod host_stats_readers; pub mod managed_child_secrets; diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index c67ec8791..186694eee 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -8790,50 +8790,12 @@ fn build_terminal_base_env( tab_id: Option<&str>, pane_id: Option<&str>, ) -> BTreeMap { - let mut out = BTreeMap::new(); - out.insert("FRESHELL".to_string(), "1".to_string()); - // `const port = Number(process.env.PORT || 3001)` (truthy: '' → 3001). - let port_raw = env - .get("PORT") - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| "3001".to_string()); - let url = env - .get("FRESHELL_URL") - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| format!("http://localhost:{}", js_number_string(&port_raw))); - out.insert("FRESHELL_URL".to_string(), url); - out.insert( - "FRESHELL_TOKEN".to_string(), - env.get("AUTH_TOKEN").unwrap_or_default(), - ); - out.insert("FRESHELL_TERMINAL_ID".to_string(), terminal_id.to_string()); - if let Some(t) = tab_id.filter(|s| !s.is_empty()) { - out.insert("FRESHELL_TAB_ID".to_string(), t.to_string()); - } - if let Some(p) = pane_id.filter(|s| !s.is_empty()) { - out.insert("FRESHELL_PANE_ID".to_string(), p.to_string()); - } - out -} - -/// JS `String(Number(s))` for the `PORT` template slot: every real deployment is -/// a plain integer; whitespace-only → `0`, unparseable → `NaN` (faithful to the -/// reference's `Number(...)` coercion in the template literal). -fn js_number_string(s: &str) -> String { - let t = s.trim(); - if t.is_empty() { - return "0".to_string(); - } - match t.parse::() { - Ok(n) if n.is_finite() => { - if n.fract() == 0.0 && n.abs() < 1e15 { - format!("{}", n as i64) - } else { - format!("{n}") - } - } - _ => "NaN".to_string(), - } + freshell_platform::freshell_context_env::freshell_context_env( + &freshell_platform::freshell_context_env::FreshellEndpoint::from_env(env), + Some(terminal_id), + tab_id, + pane_id, + ) } /// `wrapTerminalSpawnError` (`terminal-registry.ts:450-481`): the user-facing @@ -11585,14 +11547,6 @@ mod cli_create_helper_tests { assert_eq!(out2.get("FRESHELL_URL").unwrap(), "http://example:9"); } - #[test] - fn js_number_string_coercion() { - assert_eq!(js_number_string("17872"), "17872"); - assert_eq!(js_number_string(" 17872 "), "17872"); - assert_eq!(js_number_string("abc"), "NaN"); - assert_eq!(js_number_string(" "), "0"); - } - /// DEV-0006 S5.e: managed codex launch defaults ON; only the exact string /// "0" opts out. Mode scoping is unchanged: non-codex modes never plan. #[test] From 1b2e7aadaa9fc117ccc1a5e84e365f2a5bbedb0c Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:43:55 -0700 Subject: [PATCH 02/21] fix(claude-sidecar): hosted kilroy gets the managed spawn check and the fixed MCP recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session host already selects the hosted Freshell MCP for kilroy lanes (kilroy consumes the Claude launch recipes), but the sidecar offered the fixed recipe and the managed exec check to hosted claude only, so a hosted kilroy pane had no Freshell MCP at all and spawned its CLI unchecked. Both rules now apply to every hosted Claude runtime (api-hosted-ops plan §2.4 open decision). Co-Authored-By: Claude Opus 5.5 --- crates/freshell-claude-sidecar/hosted.mjs | 18 ++++++++++------- test/unit/claude-sidecar/hosted.test.ts | 24 ++++++++++++----------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/crates/freshell-claude-sidecar/hosted.mjs b/crates/freshell-claude-sidecar/hosted.mjs index fc7d55852..6c8c15c9b 100644 --- a/crates/freshell-claude-sidecar/hosted.mjs +++ b/crates/freshell-claude-sidecar/hosted.mjs @@ -2,12 +2,16 @@ // which hosted rules apply. The ONE reader of FRESHELL_HOSTED_FRESH_AGENT: // the session host sets it to the provider it hosts, which for this runtime // is `claude` (freshclaude) or `kilroy` (crates/freshell-supervisor/src/ -// backend.rs). Two rules read it, and they differ on purpose: +// backend.rs). Two rules read it: // - every hosted sidecar logs CLI output by size only (isHostedSidecar); -// - the managed spawn check and the fixed hosted MCP recipe apply to hosted -// claude alone (usesHostedClaudeRuntime). Extending them to hosted kilroy -// is an explicit item of the api-hosted-ops workstream -// (docs/plans/2026-10-22-freshclaude-api-hosted-ops.md §2.4). +// - the managed spawn check and the fixed hosted MCP recipe apply to every +// hosted Claude runtime, freshclaude and kilroy alike +// (usesHostedClaudeRuntime): both run the same Claude CLI inside the same +// managed enclosure, and kilroy consumes the Claude launch recipes +// (FreshProvider::native_provider in freshell-runtime-protocol). + +/** The providers whose hosted sidecar runs the Claude CLI. */ +const HOSTED_CLAUDE_RUNTIMES = new Set(['claude', 'kilroy']) /** The provider the session host hosts, or undefined outside a session host. */ export function hostedProvider(env = process.env) { @@ -19,7 +23,7 @@ export function isHostedSidecar(env = process.env) { return hostedProvider(env) !== undefined } -/** Hosted claude: the SDK spawn is checked against the managed exec profile and the fixed MCP recipe is offered. */ +/** Hosted claude or kilroy: the SDK spawn is checked against the managed exec profile and the fixed MCP recipe is offered. */ export function usesHostedClaudeRuntime(env = process.env) { - return hostedProvider(env) === 'claude' + return HOSTED_CLAUDE_RUNTIMES.has(hostedProvider(env)) } diff --git a/test/unit/claude-sidecar/hosted.test.ts b/test/unit/claude-sidecar/hosted.test.ts index 53ff387e7..75c8f2356 100644 --- a/test/unit/claude-sidecar/hosted.test.ts +++ b/test/unit/claude-sidecar/hosted.test.ts @@ -3,11 +3,10 @@ // One place decides whether the claude sidecar is hosted (hosted.mjs). The // session host sets FRESHELL_HOSTED_FRESH_AGENT to the provider it hosts: // `claude` for freshclaude, `kilroy` for kilroy. Every hosted sidecar logs CLI -// output by size only. The managed spawn check and the fixed hosted MCP recipe -// apply to hosted claude alone today; extending them to hosted kilroy is an -// explicit item of the api-hosted-ops workstream -// (docs/plans/2026-10-22-freshclaude-api-hosted-ops.md §2.4), not a silent -// side effect of sharing the predicate. +// output by size only, and every hosted sidecar runs the Claude CLI inside the +// managed enclosure, so the managed spawn check and the fixed hosted MCP recipe +// apply to hosted kilroy as they do to hosted claude +// (docs/plans/2026-10-22-freshclaude-api-hosted-ops.md §2.4). import { describe, expect, it } from 'vitest' // @ts-expect-error JavaScript sidecar module has no declaration file. @@ -34,18 +33,21 @@ describe('Claude sidecar hosted mode', () => { expect(isHostedSidecar(env())).toBe(false) }) - it('gives the managed spawn check and the fixed MCP recipe to hosted claude only', () => { + it('gives the managed spawn check and the fixed MCP recipe to hosted claude and hosted kilroy alike', () => { expect(usesHostedClaudeRuntime(env('claude'))).toBe(true) - expect(usesHostedClaudeRuntime(env('kilroy'))).toBe(false) + expect(usesHostedClaudeRuntime(env('kilroy'))).toBe(true) + expect(usesHostedClaudeRuntime(env(''))).toBe(false) expect(usesHostedClaudeRuntime(env())).toBe(false) - expect(hostedMcpServers(true, env('claude'))).toEqual({ freshell: { command: 'node', args: ['/opt/freshell-mcp/server.js'] } }) - expect(hostedMcpServers(true, env('kilroy'))).toBeUndefined() + const fixedRecipe = { freshell: { command: 'node', args: ['/opt/freshell-mcp/server.js'] } } + expect(hostedMcpServers(true, env('claude'))).toEqual(fixedRecipe) + expect(hostedMcpServers(true, env('kilroy'))).toEqual(fixedRecipe) expect(hostedMcpServers(true, env())).toBeUndefined() // The managed spawn is Linux-only by design. - expect(typeof managedSdkSpawn(() => {}, env('claude'))).toBe(process.platform === 'linux' ? 'function' : 'undefined') - expect(managedSdkSpawn(() => {}, env('kilroy'))).toBeUndefined() + const spawnKind = process.platform === 'linux' ? 'function' : 'undefined' + expect(typeof managedSdkSpawn(() => {}, env('claude'))).toBe(spawnKind) + expect(typeof managedSdkSpawn(() => {}, env('kilroy'))).toBe(spawnKind) expect(managedSdkSpawn(() => {}, env())).toBeUndefined() }) }) From bfb88e972c14d496c16508def4a195121fb976c9 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:46:01 -0700 Subject: [PATCH 03/21] feat(claude-sidecar): hand query() the ordinary Freshell MCP recipe and the pane context mcp-servers.mjs (absorbing hosted-mcp.mjs) picks the session's one Freshell MCP recipe: the hosted fixed command when a hosted lane selected it, otherwise the command the server resolved (create.freshellMcp); a hosted sidecar never gets the ordinary one, and no recipe ever carries an env key (the SDK puts the MCP config on the CLI's argv). The create's freshellContext ({tabId, paneId}) is merged into the SDK env, replacing any inherited pane-scoped names. The simulator ledger now records the Freshell context values (and a digest of the token) so tests can see what the CLI environment carried. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-claude-sidecar/hosted-mcp.mjs | 8 -- crates/freshell-claude-sidecar/index.mjs | 29 ++-- .../freshell-claude-sidecar/mcp-servers.mjs | 63 +++++++++ .../src/managed_provider_sdk_probe.mjs | 6 +- .../src/managed_provider_sdk_tests.rs | 4 +- test/fixtures/claude-cli-sim/query.mjs | 12 +- test/unit/claude-sidecar/hosted.test.ts | 8 +- test/unit/claude-sidecar/mcp-servers.test.ts | 126 ++++++++++++++++++ 8 files changed, 224 insertions(+), 32 deletions(-) delete mode 100644 crates/freshell-claude-sidecar/hosted-mcp.mjs create mode 100644 crates/freshell-claude-sidecar/mcp-servers.mjs create mode 100644 test/unit/claude-sidecar/mcp-servers.test.ts diff --git a/crates/freshell-claude-sidecar/hosted-mcp.mjs b/crates/freshell-claude-sidecar/hosted-mcp.mjs deleted file mode 100644 index c06fc7abf..000000000 --- a/crates/freshell-claude-sidecar/hosted-mcp.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import { usesHostedClaudeRuntime } from './hosted.mjs' - -// The managed launch context selects one fixed recipe. Scoped authentication -// stays in the inherited child environment and never enters SDK argv/IPC. -export function hostedMcpServers(selected, env = process.env) { - if (selected !== true || !usesHostedClaudeRuntime(env)) return undefined - return { freshell: { command: 'node', args: ['/opt/freshell-mcp/server.js'] } } -} diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index ec790006c..2bd12b899 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -10,14 +10,17 @@ // { type:'create', protocol:2, requestId, sessionType?, sessionId?, // boot:{kind:'fresh'} | {kind:'resume', resume} // | {kind:'fork', resume, resumeSessionAt?, dropsTurn?}, -// cwd?, model?, permissionMode?, effort?, plugins?, hostedMcp? } +// cwd?, model?, permissionMode?, effort?, plugins?, hostedMcp?, +// freshellMcp?:{command,args}, freshellContext?:{tabId?,paneId?} } // — `boot` picks the conversation the CLI boots (bootOptions): a // fork keeps the parent through-AND-including resumeSessionAt // over the raw parentUuid chain, writes a NEW conversation and // never rewrites the parent's JSONL; dropsTurn arms the SDK's // fork-time discard guard. `sessionId` is the id Rust minted for -// a fresh or forked boot, when it minted one. One sidecar hosts -// one session: a second create is refused. +// a fresh or forked boot, when it minted one. `hostedMcp` and +// `freshellMcp` pick the Freshell MCP recipe (mcp-servers.mjs); +// `freshellContext` names the pane the CLI's environment carries. +// One sidecar hosts one session: a second create is refused. // { type:'configure', sessionId, requestId, settings } // { type:'send', sessionId, text, images? } // { type:'interrupt', sessionId } @@ -111,12 +114,14 @@ // nothing. If the PROCESS itself dies mid-turn, stdout simply ends with no // `sdk.stream.ended` — the one end the Rust side rings for itself. // -// Scope discipline (ADR "keep it minimal — only what the claude T2 invariant set -// needs"): the freshell MCP-server injection (createClaudeSdkMcpServers) is -// DELIBERATELY OMITTED — a pinned pure-text T2 turn never calls a tool, MCP tools -// are not in the T2 baseline, and injecting the MCP server would spawn an extra -// node grandchild bound to the REST API. The interactive permission/question -// RESPONSE channel IS in scope (permission-channel.mjs, a faithful port of +// The Freshell MCP server (G19): the create line names the recipe, the hosted +// one or the ordinary one the Rust server resolved while +// settings.codingCli.mcpServer is on (mcp-servers.mjs; never both, never an env +// key, since the SDK puts the MCP config on the CLI's argv). The endpoint and +// token reach the MCP child through the inherited environment the Rust server +// set on this process, and the pane context (`freshellContext`) is merged into +// the SDK env here. The interactive permission/question RESPONSE channel IS in +// scope (permission-channel.mjs, a faithful port of // server/sdk-bridge.ts): canUseTool parks a pending request, surfaces // sdk.permission.request / sdk.question.request, and waits for the Rust side's // permission.respond / question.respond (unknown respond ids are stderr-log-only @@ -131,7 +136,7 @@ import { createTurnCompleteGate } from './turn-complete-gate.mjs' import { checkpointAttemptOf, rememberCheckpointAttempt, takeCheckpointIdentity } from './checkpoint-identity.mjs' import { nextMonotonic } from './monotonic-clock.mjs' import { managedSdkSpawn } from './exec-profile.mjs' -import { hostedMcpServers } from './hosted-mcp.mjs' +import { mcpServersFor, withFreshellContext } from './mcp-servers.mjs' import { cliStderrLogger, errorText, log } from './log.mjs' import { isHostedSidecar } from './hosted.mjs' import { STREAM_ENDED_EXIT_CODE, streamEnd } from './stream-end.mjs' @@ -558,7 +563,7 @@ function handleCreate(req) { plugins: Array.isArray(req.plugins) ? req.plugins.map(path => ({ type: 'local', path })) : undefined, - mcpServers: hostedMcpServers(req.hostedMcp), + mcpServers: mcpServersFor(req), pathToClaudeCodeExecutable: process.env.CLAUDE_CMD || undefined, includePartialMessages: true, // This is the SDK's native persisted-user witness. Without the replay @@ -566,7 +571,7 @@ function handleCreate(req) { // sidecar, so checkpoint identity must remain unbound. extraArgs: { 'replay-user-messages': null }, abortController: abort, - env: createClaudeSdkCleanEnv(process.env), + env: withFreshellContext(createClaudeSdkCleanEnv(process.env), req.freshellContext), settingSources: ['user', 'project', 'local'], spawnClaudeCodeProcess: managedSdkSpawn(sdkStderr), stderr: sdkStderr, diff --git a/crates/freshell-claude-sidecar/mcp-servers.mjs b/crates/freshell-claude-sidecar/mcp-servers.mjs new file mode 100644 index 000000000..da48b6b98 --- /dev/null +++ b/crates/freshell-claude-sidecar/mcp-servers.mjs @@ -0,0 +1,63 @@ +// mcp-servers.mjs — the MCP servers a session's query() gets, and the pane +// context its CLI environment carries (api-hosted-ops plan §2.4, G19). +// +// A session gets at most one Freshell MCP recipe: +// - hosted: inside a hosted Claude runtime (hosted.mjs), the session host's +// fixed command, when the lane selected it (`create.hostedMcp`). A hosted +// sidecar never gets the ordinary recipe; +// - ordinary: outside a session host, the command the Rust server resolved +// for this host while `settings.codingCli.mcpServer` is on +// (`create.freshellMcp`, `{ command, args }`). +// +// A recipe never carries an `env` key. The SDK hands the MCP config to the CLI +// on its command line (`--mcp-config `), where any process can read it, +// so the endpoint and token stay in the inherited environment: the Rust +// server sets FRESHELL/FRESHELL_URL/FRESHELL_TOKEN on this process, and the +// CLI passes its environment on to the stdio MCP child. + +import { usesHostedClaudeRuntime } from './hosted.mjs' + +/** The MCP server name both recipes register (its tools are `mcp__freshell__*`). */ +export const FRESHELL_MCP_SERVER_NAME = 'freshell' + +const HOSTED_RECIPE = Object.freeze({ command: 'node', args: Object.freeze(['/opt/freshell-mcp/server.js']) }) + +/** The pane-scoped context names: a session's CLI carries its own pane's, never another's. */ +const PANE_CONTEXT_ENV = ['FRESHELL_TERMINAL_ID', 'FRESHELL_TAB_ID', 'FRESHELL_PANE_ID'] + +/** `{ command, args }` copied from a well-formed ordinary recipe, else undefined. */ +function ordinaryRecipe(recipe) { + if (!recipe || typeof recipe !== 'object') return undefined + const { command, args } = recipe + if (typeof command !== 'string' || command.length === 0) return undefined + if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string')) return undefined + return { command, args: [...args] } +} + +/** + * The `mcpServers` option for the session `req` creates: the hosted recipe, + * the ordinary recipe, or undefined (never both, never an `env` key). + */ +export function mcpServersFor(req, env = process.env) { + if (usesHostedClaudeRuntime(env)) { + if (req?.hostedMcp !== true) return undefined + return { [FRESHELL_MCP_SERVER_NAME]: { command: HOSTED_RECIPE.command, args: [...HOSTED_RECIPE.args] } } + } + const recipe = ordinaryRecipe(req?.freshellMcp) + return recipe ? { [FRESHELL_MCP_SERVER_NAME]: recipe } : undefined +} + +/** + * `env` with the pane the session runs for (`create.freshellContext`, + * `{ tabId?, paneId? }`): the pane-scoped names come from the context alone, + * so a value inherited from elsewhere never reaches the CLI. A request with + * no context (an older server) leaves `env` as it is. + */ +export function withFreshellContext(env, context) { + if (!context || typeof context !== 'object') return env + const out = { ...env } + for (const name of PANE_CONTEXT_ENV) delete out[name] + if (typeof context.tabId === 'string' && context.tabId) out.FRESHELL_TAB_ID = context.tabId + if (typeof context.paneId === 'string' && context.paneId) out.FRESHELL_PANE_ID = context.paneId + return out +} diff --git a/crates/freshell-server/src/managed_provider_sdk_probe.mjs b/crates/freshell-server/src/managed_provider_sdk_probe.mjs index af7d343f6..222ff2cdb 100644 --- a/crates/freshell-server/src/managed_provider_sdk_probe.mjs +++ b/crates/freshell-server/src/managed_provider_sdk_probe.mjs @@ -1,7 +1,7 @@ import { readFileSync, existsSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { query } from '/opt/freshell-claude-sidecar/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs' -import { hostedMcpServers } from './hosted-mcp.mjs' +import { mcpServersFor } from './mcp-servers.mjs' import { managedSdkSpawn } from './exec-profile.mjs' const abort = new AbortController() @@ -45,14 +45,14 @@ try { const launch = managedSdkSpawn(() => {}) sdkQuery = query({ prompt, options: { cwd: process.cwd(), env, abortController: abort, - settingSources: ['user', 'project', 'local'], mcpServers: hostedMcpServers(true, env), + settingSources: ['user', 'project', 'local'], mcpServers: mcpServersFor({ hostedMcp: true }, env), spawnClaudeCodeProcess: options => { receipt.defaultChild = options.command.includes('@anthropic-ai/claude-agent-sdk-linux-x64/') && options.command.endsWith('/claude') const version = spawnSync(options.command, ['--version'], { env: options.env, encoding: 'utf8', timeout: 10_000 }) receipt.nativeVersion = /^2\.1\.237(?:\s|$)/.test(version.stdout ?? '') ? '2.1.237' : 'unexpected' const index = options.args.indexOf('--mcp-config') const config = index < 0 ? {} : JSON.parse(options.args[index + 1]) - receipt.fixedRecipe = JSON.stringify(config) === JSON.stringify({ mcpServers: hostedMcpServers(true, env) }) + receipt.fixedRecipe = JSON.stringify(config) === JSON.stringify({ mcpServers: mcpServersFor({ hostedMcp: true }, env) }) && !options.args.some(arg => arg.includes(token)) nativeChild = launch(options) const write = nativeChild.stdin.write diff --git a/crates/freshell-server/src/managed_provider_sdk_tests.rs b/crates/freshell-server/src/managed_provider_sdk_tests.rs index c9d6d6d74..23ecf61cf 100644 --- a/crates/freshell-server/src/managed_provider_sdk_tests.rs +++ b/crates/freshell-server/src/managed_provider_sdk_tests.rs @@ -11,8 +11,8 @@ async fn managed_provider_claude_default_sdk_initializes_fixed_mcp_without_a_pro .unwrap(); for (name, source) in [ ( - "hosted-mcp.mjs", - include_str!("../../freshell-claude-sidecar/hosted-mcp.mjs"), + "mcp-servers.mjs", + include_str!("../../freshell-claude-sidecar/mcp-servers.mjs"), ), ( "exec-profile.mjs", diff --git a/test/fixtures/claude-cli-sim/query.mjs b/test/fixtures/claude-cli-sim/query.mjs index 16d5beecd..2a7104080 100644 --- a/test/fixtures/claude-cli-sim/query.mjs +++ b/test/fixtures/claude-cli-sim/query.mjs @@ -48,7 +48,8 @@ // `query`, the ordinal of its query() call in // this process. Rows: `query` (the options, env // reduced to key names plus short sha256 -// digests of credential values), `boot` (the session id +// digests of credential values and the +// Freshell context values), `boot` (the session id // and the stand-in CLI's pid), `prompt` (each // command as it starts: uuid, text, content and // the model, effort and permission mode it runs @@ -1022,14 +1023,18 @@ class SimulatedCli { } /** The credentials whose value a test may need to tell apart (as a digest, never the value). */ -const DIGESTED_CREDENTIALS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'] +const DIGESTED_CREDENTIALS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'FRESHELL_TOKEN'] + +/** The non-secret Freshell context names whose values the ledger records (the token is digested). */ +const RECORDED_CONTEXT = ['FRESHELL', 'FRESHELL_URL', 'FRESHELL_TERMINAL_ID', 'FRESHELL_TAB_ID', 'FRESHELL_PANE_ID'] /** A short sha256 digest of a credential value: enough to compare, useless to read. */ const credentialDigest = (value) => createHash('sha256').update(String(value)).digest('hex').slice(0, 12) /** * The options as a ledger row: functions and controllers by kind, env by key - * names only, plus a short digest of each credential the child got. + * names only, plus a short digest of each credential the child got and the + * values of the non-secret Freshell context names. */ function describeOptions(options) { const out = {} @@ -1038,6 +1043,7 @@ function describeOptions(options) { const env = value ?? {} out.envKeys = Object.keys(env).sort() out.envDigests = Object.fromEntries(DIGESTED_CREDENTIALS.filter((name) => env[name] !== undefined).map((name) => [name, credentialDigest(env[name])])) + out.envContext = Object.fromEntries(RECORDED_CONTEXT.filter((name) => env[name] !== undefined).map((name) => [name, env[name]])) } else if (key === 'abortController') out.abortController = Boolean(value) else if (typeof value === 'function') out[key] = '[function]' else out[key] = value diff --git a/test/unit/claude-sidecar/hosted.test.ts b/test/unit/claude-sidecar/hosted.test.ts index 75c8f2356..1deea7310 100644 --- a/test/unit/claude-sidecar/hosted.test.ts +++ b/test/unit/claude-sidecar/hosted.test.ts @@ -12,7 +12,7 @@ import { describe, expect, it } from 'vitest' // @ts-expect-error JavaScript sidecar module has no declaration file. import { hostedProvider, isHostedSidecar, usesHostedClaudeRuntime } from '../../../crates/freshell-claude-sidecar/hosted.mjs' // @ts-expect-error JavaScript sidecar module has no declaration file. -import { hostedMcpServers } from '../../../crates/freshell-claude-sidecar/hosted-mcp.mjs' +import { mcpServersFor } from '../../../crates/freshell-claude-sidecar/mcp-servers.mjs' // @ts-expect-error JavaScript sidecar module has no declaration file. import { managedSdkSpawn } from '../../../crates/freshell-claude-sidecar/exec-profile.mjs' @@ -40,9 +40,9 @@ describe('Claude sidecar hosted mode', () => { expect(usesHostedClaudeRuntime(env())).toBe(false) const fixedRecipe = { freshell: { command: 'node', args: ['/opt/freshell-mcp/server.js'] } } - expect(hostedMcpServers(true, env('claude'))).toEqual(fixedRecipe) - expect(hostedMcpServers(true, env('kilroy'))).toEqual(fixedRecipe) - expect(hostedMcpServers(true, env())).toBeUndefined() + expect(mcpServersFor({ hostedMcp: true }, env('claude'))).toEqual(fixedRecipe) + expect(mcpServersFor({ hostedMcp: true }, env('kilroy'))).toEqual(fixedRecipe) + expect(mcpServersFor({ hostedMcp: true }, env())).toBeUndefined() // The managed spawn is Linux-only by design. const spawnKind = process.platform === 'linux' ? 'function' : 'undefined' diff --git a/test/unit/claude-sidecar/mcp-servers.test.ts b/test/unit/claude-sidecar/mcp-servers.test.ts new file mode 100644 index 000000000..24272ff3b --- /dev/null +++ b/test/unit/claude-sidecar/mcp-servers.test.ts @@ -0,0 +1,126 @@ +// @vitest-environment node +// +// The MCP servers a freshclaude or kilroy sidecar hands the SDK's query() +// (mcp-servers.mjs), and the pane context it adds to the CLI's environment +// (api-hosted-ops plan §2.4, G19). +// +// Two recipes exist and a session gets at most one of them: +// - the ordinary recipe: the Freshell MCP command the server resolved for +// this host (`create.freshellMcp`), outside a session host; +// - the hosted recipe: the session host's fixed command, when the lane +// selected it (`create.hostedMcp`) inside a hosted Claude runtime. +// Neither ever carries an `env` key: the SDK passes the MCP config to the CLI +// on its command line (`--mcp-config `), so the endpoint and token stay +// in the inherited environment, which the MCP child receives from the CLI. + +import { describe, expect, it } from 'vitest' +// @ts-expect-error JavaScript sidecar module has no declaration file. +import { mcpServersFor, withFreshellContext } from '../../../crates/freshell-claude-sidecar/mcp-servers.mjs' +import { startRealSidecar } from './harness' + +const HOSTED_RECIPE = { freshell: { command: 'node', args: ['/opt/freshell-mcp/server.js'] } } +const ORDINARY = { command: '/usr/bin/node', args: ['--import', '/repo/tsx/loader.mjs', '/repo/tools/freshell-mcp/server.ts'] } +const hosted = (provider = 'claude') => ({ FRESHELL_HOSTED_FRESH_AGENT: provider }) + +/** No server config in `servers` has an `env` key. */ +function expectNoEnvKey(servers: Record> | undefined) { + for (const config of Object.values(servers ?? {})) expect(Object.keys(config)).not.toContain('env') +} + +describe('mcpServersFor', () => { + it('hands an ordinary session the Freshell MCP command the server resolved', () => { + const servers = mcpServersFor({ freshellMcp: ORDINARY, hostedMcp: false }, {}) + expect(servers).toEqual({ freshell: ORDINARY }) + expectNoEnvKey(servers) + }) + + it('hands a hosted session that selected it the fixed hosted recipe, claude and kilroy alike', () => { + expect(mcpServersFor({ hostedMcp: true }, hosted('claude'))).toEqual(HOSTED_RECIPE) + expect(mcpServersFor({ hostedMcp: true }, hosted('kilroy'))).toEqual(HOSTED_RECIPE) + }) + + it('hands a session that has neither recipe no MCP servers', () => { + expect(mcpServersFor({}, {})).toBeUndefined() + expect(mcpServersFor({ hostedMcp: false, freshellMcp: null }, {})).toBeUndefined() + // The hosted selection means nothing outside a session host. + expect(mcpServersFor({ hostedMcp: true }, {})).toBeUndefined() + // A hosted sidecar whose lane did not select the hosted recipe gets none. + expect(mcpServersFor({ hostedMcp: false }, hosted())).toBeUndefined() + }) + + it('never hands a hosted sidecar the ordinary recipe: the hosted one wins, and alone it is ignored', () => { + const both = mcpServersFor({ hostedMcp: true, freshellMcp: ORDINARY }, hosted()) + expect(both).toEqual(HOSTED_RECIPE) + expect(mcpServersFor({ hostedMcp: false, freshellMcp: ORDINARY }, hosted())).toBeUndefined() + }) + + it('never puts an env key in a recipe, even when the request carries one', () => { + const servers = mcpServersFor({ freshellMcp: { ...ORDINARY, env: { FRESHELL_TOKEN: 'secret' } } }, {}) + expect(servers).toEqual({ freshell: ORDINARY }) + expectNoEnvKey(servers) + expectNoEnvKey(mcpServersFor({ hostedMcp: true }, hosted())) + }) + + it('ignores a malformed ordinary recipe instead of handing the CLI a broken server', () => { + for (const freshellMcp of [ + { args: [] }, + { command: '', args: [] }, + { command: 'node', args: 'server.js' }, + { command: 'node', args: ['server.js', 7] }, + 'node server.js', + ]) { + expect(mcpServersFor({ freshellMcp }, {}), JSON.stringify(freshellMcp)).toBeUndefined() + } + }) +}) + +describe('withFreshellContext', () => { + it('adds the pane the session runs for, and only that pane, to the environment', () => { + const inherited = { PATH: '/bin', FRESHELL_URL: 'http://localhost:9', FRESHELL_TAB_ID: 'other-tab', FRESHELL_PANE_ID: 'other-pane', FRESHELL_TERMINAL_ID: 'term-1' } + expect(withFreshellContext(inherited, { tabId: 'tab-1', paneId: 'pane-1' })).toEqual({ + PATH: '/bin', FRESHELL_URL: 'http://localhost:9', FRESHELL_TAB_ID: 'tab-1', FRESHELL_PANE_ID: 'pane-1', + }) + // A context without a pane id names no pane. + expect(withFreshellContext(inherited, { tabId: 'tab-1' })).toEqual({ PATH: '/bin', FRESHELL_URL: 'http://localhost:9', FRESHELL_TAB_ID: 'tab-1' }) + }) + + it('leaves the environment alone when the request carries no context (an older server)', () => { + const inherited = { PATH: '/bin', FRESHELL_TAB_ID: 'tab-0' } + expect(withFreshellContext(inherited, undefined)).toEqual(inherited) + expect(withFreshellContext(inherited, null)).toEqual(inherited) + }) +}) + +describe('the sidecar hands query() the MCP recipe and the pane context', () => { + it('passes an ordinary recipe without env and puts the context in the CLI environment', async () => { + const sidecar = await startRealSidecar({ + env: { FRESHELL: '1', FRESHELL_URL: 'http://localhost:4100', FRESHELL_TOKEN: 'tok-mcp', FRESHELL_TAB_ID: 'stale-tab', FRESHELL_TERMINAL_ID: 'stale-terminal' }, + }) + await sidecar.create({ freshellMcp: ORDINARY, freshellContext: { tabId: 'tab-7', paneId: 'pane-7' } }) + const { options } = await sidecar.waitForLedger((row) => row.kind === 'query', 'the query() options') + expect(options.mcpServers).toEqual({ freshell: ORDINARY }) + expectNoEnvKey(options.mcpServers) + expect(options.envContext).toEqual({ + FRESHELL: '1', + FRESHELL_URL: 'http://localhost:4100', + FRESHELL_TAB_ID: 'tab-7', + FRESHELL_PANE_ID: 'pane-7', + }) + expect(options.envKeys).not.toContain('FRESHELL_TERMINAL_ID') + expect(options.envDigests.FRESHELL_TOKEN).toMatch(/^[0-9a-f]{12}$/) + }) + + it('passes no MCP servers when the server sent no recipe', async () => { + const sidecar = await startRealSidecar() + await sidecar.create({ freshellMcp: null, freshellContext: null }) + const { options } = await sidecar.waitForLedger((row) => row.kind === 'query', 'the query() options') + expect(options.mcpServers).toBeUndefined() + }) + + it('passes the fixed hosted recipe, never the ordinary one, in a hosted kilroy sidecar', async () => { + const sidecar = await startRealSidecar({ env: hosted('kilroy') }) + await sidecar.create({ sessionType: 'kilroy', hostedMcp: true, freshellMcp: ORDINARY }) + const { options } = await sidecar.waitForLedger((row) => row.kind === 'query', 'the query() options') + expect(options.mcpServers).toEqual(HOSTED_RECIPE) + }) +}) From b786894a55a55c40d180d42b2cb98aed89f90d86 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:52:04 -0700 Subject: [PATCH 04/21] feat(protocol): freshAgent create and attach name the pane they run for FreshAgentCreate gains an optional paneId (tabId already existed) and FreshAgentAttach optional tabId/paneId, in the zod schema, the frozen contract and the Rust structs. Absent fields stay absent on re-serialize and older frames still parse. The pane context is what the agent's Freshell MCP will act for (G19). Existing struct literals get the new fields as None. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 7 ++++ .../src/claude/end_of_output_tests.rs | 1 + .../src/claude/live_frames_tests.rs | 1 + crates/freshell-freshagent/src/codex.rs | 34 +++++++++++++++ .../src/codex_sidecar_tracking_tests.rs | 3 ++ crates/freshell-freshagent/src/opencode_ws.rs | 9 ++++ .../src/session_handoff/tests.rs | 14 +++++++ .../freshell-protocol/src/client_messages.rs | 11 +++++ crates/freshell-protocol/tests/roundtrip.rs | 38 +++++++++++++++++ .../src/fresh_agent_proxy_rest.rs | 1 + .../src/providers/fresh_agent.rs | 1 + .../freshagent_claude_kill_after_restart.rs | 3 ++ port/contract/ws-protocol.schema.json | 36 ++++++++++++++++ shared/ws-protocol.ts | 8 ++++ ...-protocol.fresh-agent-pane-context.test.ts | 41 +++++++++++++++++++ 15 files changed, 208 insertions(+) create mode 100644 test/unit/shared/ws-protocol.fresh-agent-pane-context.test.ts diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 57a4fac54..4f4b90020 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -1447,6 +1447,8 @@ impl FreshClaudeState { } }; let msg = FreshAgentAttach { + tab_id: None, + pane_id: None, provider: freshell_protocol::AgentProvider::Claude, session_id: session_id.to_string(), session_type: flavor, @@ -8674,6 +8676,8 @@ pub(crate) mod tests { fn attach_msg(session_id: &str) -> FreshAgentAttach { FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: None, observed_generation: None, provider: freshell_protocol::AgentProvider::Claude, @@ -11246,6 +11250,7 @@ rl.on('line', (line) => { fn dedup_create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -12721,6 +12726,8 @@ rl.on('line', (line) => { // A delayed ATTACH: the tracked-and-alive fast path is BLOCKED // with the typed error (pre-d3: the silent parity return). st.handle_attach(freshell_protocol::FreshAgentAttach { + tab_id: None, + pane_id: None, provider: freshell_protocol::AgentProvider::Claude, session_id: placeholder.clone(), session_type: SessionType::Freshclaude, diff --git a/crates/freshell-freshagent/src/claude/end_of_output_tests.rs b/crates/freshell-freshagent/src/claude/end_of_output_tests.rs index 5f7a4f662..4ea477505 100644 --- a/crates/freshell-freshagent/src/claude/end_of_output_tests.rs +++ b/crates/freshell-freshagent/src/claude/end_of_output_tests.rs @@ -252,6 +252,7 @@ impl Drop for EndingSession { fn create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, diff --git a/crates/freshell-freshagent/src/claude/live_frames_tests.rs b/crates/freshell-freshagent/src/claude/live_frames_tests.rs index d9b93c0cd..c2ff7d261 100644 --- a/crates/freshell-freshagent/src/claude/live_frames_tests.rs +++ b/crates/freshell-freshagent/src/claude/live_frames_tests.rs @@ -179,6 +179,7 @@ impl Drop for ClaudeEnvReset { fn create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index cafb9f94e..e312db399 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -12580,6 +12580,7 @@ pub(crate) mod tests { let sink = crate::naming::test_support::RecordingSink::new(); st.set_session_naming(sink.clone()); let create = FreshAgentCreate { + pane_id: None, observed_epoch: None, observed_generation: None, naming_handle: Some("handle-codex-early".to_string()), @@ -14560,6 +14561,7 @@ pub(crate) mod tests { let create = tokio::spawn(async move { st2.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -14735,6 +14737,7 @@ pub(crate) mod tests { configure_fake_codex_cmd("{}"); st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -14895,6 +14898,7 @@ pub(crate) mod tests { let create_task = tokio::spawn(async move { st2.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -22075,6 +22079,7 @@ pub(crate) mod tests { let (st, mut rx) = state_with_bus(); st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -22132,6 +22137,8 @@ pub(crate) mod tests { fn attach_msg(session_id: &str) -> FreshAgentAttach { FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: None, observed_generation: None, provider: freshell_protocol::AgentProvider::Codex, @@ -22587,6 +22594,8 @@ pub(crate) mod tests { .await; st.handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: None, observed_generation: None, provider: freshell_protocol::AgentProvider::Codex, @@ -22766,6 +22775,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -22819,6 +22829,7 @@ pub(crate) mod tests { /// `request_id`. fn create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -23164,6 +23175,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -23234,6 +23246,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -23315,6 +23328,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -23384,6 +23398,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -24012,6 +24027,7 @@ pub(crate) mod tests { ) -> String { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -24063,6 +24079,7 @@ pub(crate) mod tests { ) -> String { st.handle_create( FreshAgentCreate { + pane_id: None, observed_epoch: None, observed_generation: None, naming_handle: Some(naming_handle.to_string()), @@ -24259,6 +24276,7 @@ pub(crate) mod tests { state .handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -24328,6 +24346,7 @@ pub(crate) mod tests { state .handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -24410,6 +24429,7 @@ pub(crate) mod tests { state .handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -24839,6 +24859,8 @@ pub(crate) mod tests { configure_fake_codex_cmd("{}"); st.handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: None, observed_generation: None, provider: freshell_protocol::AgentProvider::Codex, @@ -24934,6 +24956,7 @@ pub(crate) mod tests { let create_task = tokio::spawn(async move { st2.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -25094,6 +25117,8 @@ pub(crate) mod tests { let attach_task = tokio::spawn(async move { st_attach .handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: None, observed_generation: None, provider: freshell_protocol::AgentProvider::Codex, @@ -25190,6 +25215,8 @@ pub(crate) mod tests { configure_fake_codex_cmd("{}"); st.handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: None, observed_generation: None, provider: freshell_protocol::AgentProvider::Codex, @@ -27868,6 +27895,8 @@ pub(crate) mod tests { // THE STALE ATTACH: observed generation is the PRE-advance value — // typed refusal (SESSION_RESERVED), never a recreation. st.handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: Some(before.epoch), observed_generation: Some(before.generation), provider: freshell_protocol::AgentProvider::Codex, @@ -28720,6 +28749,7 @@ pub(crate) mod tests { task_st .handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -28830,6 +28860,7 @@ pub(crate) mod tests { task_st .handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -28945,6 +28976,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -29358,6 +29390,7 @@ pub(crate) mod tests { // adopted without consulting the coordinator. st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -29448,6 +29481,7 @@ pub(crate) mod tests { // observed generation — the fence discipline refuses typed. st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: Some(registry.boot_epoch()), observed_generation: Some(stale_generation), diff --git a/crates/freshell-freshagent/src/codex_sidecar_tracking_tests.rs b/crates/freshell-freshagent/src/codex_sidecar_tracking_tests.rs index 15cbbaf37..07ae9c7e3 100644 --- a/crates/freshell-freshagent/src/codex_sidecar_tracking_tests.rs +++ b/crates/freshell-freshagent/src/codex_sidecar_tracking_tests.rs @@ -95,6 +95,7 @@ async fn create_tracked_session_with_resume( ) -> String { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -226,6 +227,7 @@ async fn failed_spawn_leaves_no_record() { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -627,6 +629,7 @@ async fn create_bail_after_spawn_leaves_no_record() { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, diff --git a/crates/freshell-freshagent/src/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index 5107643c5..5f58d3ec3 100644 --- a/crates/freshell-freshagent/src/opencode_ws.rs +++ b/crates/freshell-freshagent/src/opencode_ws.rs @@ -9720,6 +9720,7 @@ mod tests { fn create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -10528,6 +10529,8 @@ mod tests { // the terminal-owner gate must answer the TYPED refusal (never // the wildcard reuse that restarts the serve bridge). st.handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: real_id.clone(), session_type: SessionType::Freshopencode, @@ -10636,6 +10639,8 @@ mod tests { // THE DELAYED ATTACH with the STALE pair — the state precheck sees // Live (passes); the ADOPT validates the pair and refuses typed. st.handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: real_id.clone(), session_type: SessionType::Freshopencode, @@ -12912,6 +12917,8 @@ mod tests { fn attach_msg(session_id: &str) -> FreshAgentAttach { FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: None, observed_generation: None, provider: AgentProvider::Opencode, @@ -24760,6 +24767,8 @@ mod tests { // THE MAP-HIT FENCED ATTACH — the incident's three wasted attempts, // now the documented recovery verb. st.handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: durable.clone(), session_type: SessionType::Freshopencode, diff --git a/crates/freshell-freshagent/src/session_handoff/tests.rs b/crates/freshell-freshagent/src/session_handoff/tests.rs index 731593dd7..76f44eef4 100644 --- a/crates/freshell-freshagent/src/session_handoff/tests.rs +++ b/crates/freshell-freshagent/src/session_handoff/tests.rs @@ -761,6 +761,7 @@ async fn unsupported_atomic_handoff_refuses_before_mutation_and_clear_bypasses_p fn create_msg(sid: &str) -> FreshAgentCreate { FreshAgentCreate { + pane_id: None, naming_handle: None, request_id: format!("handoff-create-{}", uuid::Uuid::new_v4()), session_type: SessionType::Freshclaude, @@ -6953,6 +6954,7 @@ const FRESH_CREATE_DURABLE_ID: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; fn fresh_create_msg(session_type: SessionType) -> FreshAgentCreate { FreshAgentCreate { + pane_id: None, naming_handle: None, request_id: format!("handoff-create-fresh-{}", uuid::Uuid::new_v4()), session_type, @@ -7231,6 +7233,8 @@ async fn opencode_handoff_keeps_shared_serve_alive_and_session_id_stable() { // serve (the attach-resume lane — get_session on the fake serve answers). rig.fresh_opencode .handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: sid.clone(), session_type: SessionType::Freshopencode, @@ -7362,6 +7366,8 @@ async fn an_opencode_placeholder_handoff_resolves_the_durable_owner_and_complete // identity the pane resolves to after the first send). rig.fresh_opencode .handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: durable_id.clone(), session_type: SessionType::Freshopencode, @@ -7485,6 +7491,8 @@ async fn opencode_handoff_stop_aborts_the_active_turn_through_the_manager_before // shared serve (the attach-resume lane — the test-6 pattern). rig.fresh_opencode .handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: sid.clone(), session_type: SessionType::Freshopencode, @@ -7628,6 +7636,8 @@ async fn opencode_handoff_stop_aborts_a_daemon_turn_left_running_by_a_finished_l // shared serve (the test-6 pattern). rig.fresh_opencode .handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: sid.clone(), session_type: SessionType::Freshopencode, @@ -7772,6 +7782,8 @@ async fn opencode_handoff_during_compaction_aborts_the_daemon_side_summarize() { // shared serve (the test-6 pattern). rig.fresh_opencode .handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: sid.clone(), session_type: SessionType::Freshopencode, @@ -8777,6 +8789,8 @@ async fn handoff_route_resolves_absent_session_type_when_unambiguous() { // Fresh owner: a durable opencode session through the shared serve. rig.fresh_opencode .handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: sid.clone(), session_type: SessionType::Freshopencode, diff --git a/crates/freshell-protocol/src/client_messages.rs b/crates/freshell-protocol/src/client_messages.rs index b18997051..3ada8213c 100644 --- a/crates/freshell-protocol/src/client_messages.rs +++ b/crates/freshell-protocol/src/client_messages.rs @@ -900,6 +900,11 @@ pub struct FreshAgentCreate { /// optional; conn-less (REST/MCP) creates omit it. #[serde(skip_serializing_if = "Option::is_none")] pub tab_id: Option, + /// G19: the creating pane's id. With `tab_id` it is the pane context the + /// agent's Freshell MCP acts for (`FRESHELL_TAB_ID`/`FRESHELL_PANE_ID`). + /// Additive optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option, /// Unified agent names (Task 1 wire / Task 2 Rust side): the pane's /// pre-durable naming handle — see `TerminalCreate::naming_handle`. /// Additive optional. @@ -942,6 +947,12 @@ pub struct FreshAgentAttach { pub resume_session_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub session_ref: Option, + /// G19: the attaching pane. An attach that resumes the session hands the + /// agent's Freshell MCP this pane context. Additive optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/freshell-protocol/tests/roundtrip.rs b/crates/freshell-protocol/tests/roundtrip.rs index 0577a1381..b1f957380 100644 --- a/crates/freshell-protocol/tests/roundtrip.rs +++ b/crates/freshell-protocol/tests/roundtrip.rs @@ -880,3 +880,41 @@ fn session_name_updated_broadcast_roundtrips_and_conforms() { other => panic!("expected SessionNameUpdated, got {other:?}"), } } + +/// G19 (api-hosted-ops plan §2.4): a fresh-agent create and attach name the pane +/// they run for (`tabId`, `paneId`), so the agent's Freshell MCP acts for that +/// pane. Both are additive optionals: present ones round-trip, absent ones stay +/// absent on re-serialize, and frames from older clients still parse. +#[test] +fn fresh_agent_create_and_attach_carry_the_pane_they_run_for() { + let wire = r#"{"type":"freshAgent.create","requestId":"r1","sessionType":"freshclaude","provider":"claude","tabId":"tab-1","paneId":"pane-1"}"#; + match client_roundtrip(wire, "freshAgent.create") { + ClientMessage::FreshAgentCreate(m) => { + assert_eq!(m.tab_id.as_deref(), Some("tab-1")); + assert_eq!(m.pane_id.as_deref(), Some("pane-1")); + } + other => panic!("expected FreshAgentCreate, got {other:?}"), + } + let wire = r#"{"type":"freshAgent.create","requestId":"r2","sessionType":"kilroy","provider":"claude"}"#; + match client_roundtrip(wire, "freshAgent.create") { + ClientMessage::FreshAgentCreate(m) => assert_eq!(m.pane_id, None), + other => panic!("expected FreshAgentCreate, got {other:?}"), + } + + let wire = r#"{"type":"freshAgent.attach","sessionId":"s1","sessionType":"freshclaude","provider":"claude","tabId":"tab-1","paneId":"pane-1"}"#; + match client_roundtrip(wire, "freshAgent.attach") { + ClientMessage::FreshAgentAttach(m) => { + assert_eq!(m.tab_id.as_deref(), Some("tab-1")); + assert_eq!(m.pane_id.as_deref(), Some("pane-1")); + } + other => panic!("expected FreshAgentAttach, got {other:?}"), + } + let wire = r#"{"type":"freshAgent.attach","sessionId":"s1","sessionType":"freshcodex","provider":"codex"}"#; + match client_roundtrip(wire, "freshAgent.attach") { + ClientMessage::FreshAgentAttach(m) => { + assert_eq!(m.tab_id, None); + assert_eq!(m.pane_id, None); + } + other => panic!("expected FreshAgentAttach, got {other:?}"), + } +} diff --git a/crates/freshell-server/src/fresh_agent_proxy_rest.rs b/crates/freshell-server/src/fresh_agent_proxy_rest.rs index 25d6334dd..01368a2ab 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_rest.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_rest.rs @@ -129,6 +129,7 @@ impl HostedFreshAgentRestGateway for HostedFreshAgentProxy { rest_agent_identity(&request.provider, &request.session_type)?; let runtime_provider = fresh_provider(&Some(provider), session_type).ok_or(())?; let message = freshell_protocol::FreshAgentCreate { + pane_id: None, request_id: request.request_id, session_type, cwd: request.cwd, diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index fee32b87f..66bcfb67b 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -1099,6 +1099,7 @@ fn create_request_for_profile(profile: &FreshAgentProfile) -> FreshAgentCreate { ), }; FreshAgentCreate { + pane_id: None, request_id: format!("host-create-{}", uuid::Uuid::new_v4()), observed_epoch: None, observed_generation: None, diff --git a/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs b/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs index 2863afb0a..ecd0fdf86 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs @@ -139,6 +139,7 @@ fn new_generation( fn create_msg_resume(request_id: &str, durable: &str) -> FreshAgentCreate { FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -164,6 +165,8 @@ fn create_msg_resume(request_id: &str, durable: &str) -> FreshAgentCreate { fn attach_msg(seat: &str, durable: &str) -> FreshAgentAttach { FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: None, observed_generation: None, provider: AgentProvider::Claude, diff --git a/port/contract/ws-protocol.schema.json b/port/contract/ws-protocol.schema.json index 2b72ca5ad..e89f919f6 100644 --- a/port/contract/ws-protocol.schema.json +++ b/port/contract/ws-protocol.schema.json @@ -1906,6 +1906,10 @@ "minimum": 0, "type": "integer" }, + "paneId": { + "minLength": 1, + "type": "string" + }, "permissionMode": { "type": "string" }, @@ -1997,6 +2001,10 @@ "minimum": 0, "type": "integer" }, + "paneId": { + "minLength": 1, + "type": "string" + }, "provider": { "enum": [ "claude", @@ -2039,6 +2047,10 @@ ], "type": "string" }, + "tabId": { + "minLength": 1, + "type": "string" + }, "type": { "const": "freshAgent.attach", "type": "string" @@ -3267,6 +3279,10 @@ "minimum": 0, "type": "integer" }, + "paneId": { + "minLength": 1, + "type": "string" + }, "provider": { "enum": [ "claude", @@ -3309,6 +3325,10 @@ ], "type": "string" }, + "tabId": { + "minLength": 1, + "type": "string" + }, "type": { "const": "freshAgent.attach", "type": "string" @@ -3489,6 +3509,10 @@ "minimum": 0, "type": "integer" }, + "paneId": { + "minLength": 1, + "type": "string" + }, "permissionMode": { "type": "string" }, @@ -3580,6 +3604,10 @@ "minimum": 0, "type": "integer" }, + "paneId": { + "minLength": 1, + "type": "string" + }, "provider": { "enum": [ "claude", @@ -3622,6 +3650,10 @@ ], "type": "string" }, + "tabId": { + "minLength": 1, + "type": "string" + }, "type": { "const": "freshAgent.attach", "type": "string" @@ -4532,6 +4564,10 @@ "minimum": 0, "type": "integer" }, + "paneId": { + "minLength": 1, + "type": "string" + }, "permissionMode": { "type": "string" }, diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index 48c2dcda8..9eb375a16 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -867,6 +867,10 @@ export const FreshAgentCreateSchema = z.object({ /** D8: the creating tab's client-side id; the server composes the ledger row's * `tabKey` as `deviceId:tabId`. Non-strict schema — tolerated by older servers. */ tabId: z.string().min(1).optional(), + /** G19: the creating pane's id. With `tabId` it is the pane context the + * agent's Freshell MCP acts for (FRESHELL_TAB_ID / FRESHELL_PANE_ID). + * Additive optional. */ + paneId: z.string().min(1).optional(), /** Unified agent names (Task 1): the pane's pre-durable naming handle — see * `terminal.create.namingHandle`. Additive optional. */ namingHandle: z.string().min(1).optional(), @@ -886,6 +890,10 @@ export const FreshAgentAttachSchema = z.object({ resumeSessionId: z.string().optional(), cwd: z.string().optional(), sessionRef: SessionLocatorSchema.optional(), + /** G19: the attaching pane. An attach that resumes the session hands the + * agent's Freshell MCP this pane context. Additive optional. */ + tabId: z.string().min(1).optional(), + paneId: z.string().min(1).optional(), /** kata b8ke delayed-request fence: attach can cold-resume an untracked * session (registering a runtime), so it carries the observed * (epoch, generation) pair. */ diff --git a/test/unit/shared/ws-protocol.fresh-agent-pane-context.test.ts b/test/unit/shared/ws-protocol.fresh-agent-pane-context.test.ts new file mode 100644 index 000000000..043a297a0 --- /dev/null +++ b/test/unit/shared/ws-protocol.fresh-agent-pane-context.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { ClientMessageSchema } from '@shared/ws-protocol' + +// G19 (api-hosted-ops plan §2.4): a fresh-agent create and attach name the +// pane they run for, so the agent's Freshell MCP acts for that pane. The +// fields are optional; the client schema keeps them (zod strips unknown keys, +// so a field missing from the schema would never reach the server). +describe('freshAgent create/attach pane context', () => { + it('keeps the create\'s tabId and paneId', () => { + const create = { + type: 'freshAgent.create', + requestId: 'req-1', + sessionType: 'freshclaude', + provider: 'claude', + tabId: 'tab-1', + paneId: 'pane-1', + } + expect(ClientMessageSchema.parse(create)).toEqual(create) + }) + + it('keeps the attach\'s tabId and paneId', () => { + const attach = { + type: 'freshAgent.attach', + sessionId: 'session-1', + sessionType: 'kilroy', + provider: 'claude', + tabId: 'tab-1', + paneId: 'pane-1', + } + expect(ClientMessageSchema.parse(attach)).toEqual(attach) + }) + + it('still accepts frames without them, and refuses empty ids', () => { + const attach = { type: 'freshAgent.attach', sessionId: 'session-1', sessionType: 'freshclaude', provider: 'claude' } + expect(ClientMessageSchema.parse(attach)).toEqual(attach) + expect(ClientMessageSchema.safeParse({ ...attach, paneId: '' }).success).toBe(false) + expect(ClientMessageSchema.safeParse({ + type: 'freshAgent.create', requestId: 'req-2', sessionType: 'freshclaude', paneId: '', + }).success).toBe(false) + }) +}) From 4ba61548280729015c668af448a82102f89a38fc Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:03:23 -0700 Subject: [PATCH 05/21] feat(freshclaude): every sidecar launch gets the Freshell MCP, its endpoint and its pane A claude lane can now be wired with the server's endpoint and a Freshell MCP recipe provider (set-once, like the identity sink). Every sidecar's environment carries FRESHELL=1, FRESHELL_URL and FRESHELL_TOKEN from that endpoint and none of another pane's ids; the provider is asked at each launch in boot_sidecar, the one spawn path, so the recipe reaches the create, attach-resume, handoff resume, undo/redo respawn and fork alike, and a setting change applies to the next launch. A hosted lane never gets the ordinary recipe. ClaudeLaunchSpec carries freshellMcp and freshellContext; the pane comes from the create's or attach's tabId and paneId, and an undo replacement or fork child inherits its session's. Each launch logs freshagent.claude.mcp_injection, and an unresolvable command logs freshagent.claude.mcp_unavailable (warn) and starts the pane without it. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 58 ++- .../src/claude/establish.rs | 4 + crates/freshell-freshagent/src/claude/fork.rs | 7 +- .../src/claude/rollback.rs | 6 +- .../src/claude/teardown_tests.rs | 2 +- .../src/claude_freshell_mcp.rs | 239 +++++++++++ .../freshell-freshagent/src/claude_launch.rs | 88 +++- .../freshell-freshagent/src/claude_spawn.rs | 87 ++++ crates/freshell-freshagent/src/lib.rs | 1 + crates/freshell-ws/tests/common/claude_rig.rs | 24 ++ .../tests/freshagent_claude_mcp_launch.rs | 391 ++++++++++++++++++ 11 files changed, 894 insertions(+), 13 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude_freshell_mcp.rs create mode 100644 crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 4f4b90020..b07d51e2c 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -115,6 +115,11 @@ pub struct FreshClaudeState { /// Host-only selection of the fixed Freshell stdio MCP recipe. Its endpoint /// and grant remain in the child environment, never in create IPC. hosted_mcp: bool, + /// The ordinary Freshell MCP (G19): the endpoint every sidecar's + /// environment carries and the recipe provider asked at each launch, both + /// wired by `freshell-server` ([`Self::set_freshell_endpoint`], + /// [`Self::set_freshell_mcp_provider`]); unwired in the session host. + freshell_mcp: crate::claude_freshell_mcp::ClaudeFreshellMcpWiring, /// Test seam (`test-support` feature, C04 rigs): the complete environment of /// every claude sidecar this state spawns, replacing the inherited one (the /// ownership tag is still layered on). `None` in production: the sidecar @@ -732,6 +737,9 @@ struct ClaudeConfiguration { commands: Option>, needs_configure: bool, runtime_cwd: Option, + /// The pane this runtime was launched for (G19): an undo's replacement and + /// a fork's child launch for the same pane. + freshell_context: Option, } impl ClaudeSession { @@ -795,6 +803,7 @@ impl FreshClaudeState { pub fn new(broadcast_tx: Arc>) -> Self { Self { hosted_mcp: false, + freshell_mcp: Default::default(), sidecar_environment: None, broadcast_tx, checkpoint_event_sink: crate::CheckpointEventSink::default(), @@ -838,6 +847,26 @@ impl FreshClaudeState { self } + /// Wire where every sidecar's CLI reaches this server (G19): its + /// environment carries `FRESHELL=1`, `FRESHELL_URL` and `FRESHELL_TOKEN`. + /// Set-once; `false` when an endpoint was already wired. + pub fn set_freshell_endpoint( + &self, + endpoint: crate::claude_freshell_mcp::FreshellEndpoint, + ) -> bool { + self.freshell_mcp.set_endpoint(endpoint) + } + + /// Wire the Freshell MCP recipe provider (G19), asked at every sidecar + /// launch of a lane that did not select the hosted recipe. Set-once; + /// `false` when a provider was already wired. + pub fn set_freshell_mcp_provider( + &self, + provider: crate::claude_freshell_mcp::ClaudeFreshellMcpProvider, + ) -> bool { + self.freshell_mcp.set_provider(provider) + } + /// Test seam (see [`Self::sidecar_environment`]): every sidecar this state /// spawns gets exactly `environment` (plus its ownership tag) instead of /// inheriting the server's environment. @@ -887,7 +916,12 @@ impl FreshClaudeState { /// Spawn one claude sidecar process (create, attach-resume and rollback all /// come through here) for a session of `session_type`. async fn spawn_sidecar_process(&self, session_type: &str) -> Result { - spawn_sidecar(self.sidecar_environment.as_deref(), session_type).await + spawn_sidecar( + self.sidecar_environment.as_deref(), + self.freshell_mcp.endpoint(), + session_type, + ) + .await } pub fn set_checkpoint_event_persister( @@ -2425,7 +2459,13 @@ impl FreshClaudeState { self.hosted_mcp, ) .with_session_id(boot_session_id) - .with_model_selection(serde_json::to_value(&msg.model_selection).ok()); + .with_model_selection(serde_json::to_value(&msg.model_selection).ok()) + .with_freshell_context( + crate::claude_freshell_mcp::ClaudeFreshellContext::from_ids( + msg.tab_id.as_deref(), + msg.pane_id.as_deref(), + ), + ); // b8ke focused episode-2 round-2 F2: the create's claim owns this // runtime's coordinator transition (the session-init adoption's Blocked // arm correlates its own in-flight claim through it). @@ -5716,7 +5756,13 @@ impl FreshClaudeState { &rec, self.hosted_mcp, ) - .with_session_id(minted_id); + .with_session_id(minted_id) + .with_freshell_context( + crate::claude_freshell_mcp::ClaudeFreshellContext::from_ids( + msg.tab_id.as_deref(), + msg.pane_id.as_deref(), + ), + ); // Register under the CLIENT's id: the consumer stamps the map key on every // envelope and the frozen client routes by envelope sessionId // (fresh-agent-ws.ts:180-183) -- a fresh key would strand the pane. The @@ -7979,6 +8025,7 @@ pub(crate) fn sidecar_node_command( /// `test-support` seam); otherwise it inherits this process's. async fn spawn_sidecar( environment: Option<&[(String, String)]>, + freshell_endpoint: Option<&crate::claude_freshell_mcp::FreshellEndpoint>, session_type: &str, ) -> Result { let entry = sidecar_entry_path(); @@ -7998,7 +8045,8 @@ async fn spawn_sidecar( crate::claude_spawn::ClaudeNodeCommand::new(&entry), &ownership_id, ) - .with_complete_environment(environment); + .with_complete_environment(environment) + .with_freshell_endpoint(freshell_endpoint); let node = command.node().to_string(); let (mut child, tree_guard) = command.spawn_guarded().map_err(|error| match error { crate::claude_spawn::ClaudeSpawnError::ExecProfile(classification) => classification, @@ -8049,7 +8097,7 @@ mod provider_exec_tests { .collect(), ) .unwrap(); - let mut result = spawn_sidecar(None, "freshclaude").await; + let mut result = spawn_sidecar(None, None, "freshclaude").await; if let Ok(SpawnedSidecar { child, .. }) = &mut result { let pid = child.id().unwrap(); child.kill().await.unwrap(); diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index 86ef0a105..879bf14f4 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -403,6 +403,9 @@ impl FreshClaudeState { .spawn_sidecar_process(&spec.session_type) .await .map_err(EstablishError::Spawn)?; + // The Freshell MCP recipe is resolved per launch (G19), so the + // setting applies to the next sidecar without a restart. + let spec = &self.freshell_mcp.launch(spec.clone()).await; // Arm the lease's TTL tree-kill path now that the child and its tag exist. if let Some(guard) = lease_guard { if let Some(pid) = child.id() { @@ -734,6 +737,7 @@ impl FreshClaudeState { ClaudeSession { configuration: ClaudeConfiguration { settings: configuration_settings, + freshell_context: spec.freshell_context.clone(), ..ClaudeConfiguration::default() }, stdin, diff --git a/crates/freshell-freshagent/src/claude/fork.rs b/crates/freshell-freshagent/src/claude/fork.rs index 74011376e..383e519d8 100644 --- a/crates/freshell-freshagent/src/claude/fork.rs +++ b/crates/freshell-freshagent/src/claude/fork.rs @@ -98,6 +98,9 @@ struct ForkParent { /// The settings the parent runs with (model, effort, permission mode, /// plugins, cwd): the child starts with the same. settings: crate::identity_sink::FreshAgentSettings, + /// The pane the parent runs for (G19): the pane follows the child, so the + /// child runs for it too. + freshell_context: Option, } impl FreshClaudeState { @@ -368,7 +371,8 @@ impl FreshClaudeState { &launch_settings, self.hosted_mcp, ) - .with_session_id(Some(child.clone())); + .with_session_id(Some(child.clone())) + .with_freshell_context(parent.freshell_context.clone()); let established = match self .establish_runtime( EstablishPlan { @@ -533,6 +537,7 @@ impl FreshClaudeState { .unwrap_or_else(|| live_key.clone()), in_turn: Arc::clone(&session.in_turn), settings: session.configuration.settings.clone(), + freshell_context: session.configuration.freshell_context.clone(), }) } diff --git a/crates/freshell-freshagent/src/claude/rollback.rs b/crates/freshell-freshagent/src/claude/rollback.rs index c674ca9d6..196a5b5ac 100644 --- a/crates/freshell-freshagent/src/claude/rollback.rs +++ b/crates/freshell-freshagent/src/claude/rollback.rs @@ -213,6 +213,7 @@ impl FreshClaudeState { session_type, last_synthesized_complete_at, live_settings, + freshell_context, ) = { let guard = self.sessions.lock().await; match guard.get(&map_key) { @@ -230,6 +231,8 @@ impl FreshClaudeState { // The settings the session runs with (G15): the // replacement launches with them, on every lane. s.configuration.settings.clone(), + // The pane it runs for (G19): the replacement's too. + s.configuration.freshell_context.clone(), ), None => { reply_sink(rollback_error_frame( @@ -1070,7 +1073,8 @@ impl FreshClaudeState { &launch_settings, self.hosted_mcp, ) - .with_session_id(Some(replacement.clone())); + .with_session_id(Some(replacement.clone())) + .with_freshell_context(freshell_context); // A lease the rollback took on the original completes onto the // replacement (the original's durable id resolves to it); a lease the // original already held as its live owner is cleared once it is gone. diff --git a/crates/freshell-freshagent/src/claude/teardown_tests.rs b/crates/freshell-freshagent/src/claude/teardown_tests.rs index d18b10da6..1bc904958 100644 --- a/crates/freshell-freshagent/src/claude/teardown_tests.rs +++ b/crates/freshell-freshagent/src/claude/teardown_tests.rs @@ -342,7 +342,7 @@ async fn spawn_sidecar_runs_the_sidecar_inside_its_own_process_group() { let script = dir.path().join("idle-sidecar.mjs"); std::fs::write(&script, "setInterval(() => {}, 1000)\n").unwrap(); std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - let spawned = spawn_sidecar(None, "freshclaude").await; + let spawned = spawn_sidecar(None, None, "freshclaude").await; std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); let SpawnedSidecar { diff --git a/crates/freshell-freshagent/src/claude_freshell_mcp.rs b/crates/freshell-freshagent/src/claude_freshell_mcp.rs new file mode 100644 index 000000000..001099869 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_freshell_mcp.rs @@ -0,0 +1,239 @@ +//! The ordinary Freshell MCP server inside freshclaude and kilroy panes (G19, +//! api-hosted-ops plan §2.4). +//! +//! Terminal panes give their coding CLI the Freshell MCP server and the context +//! it needs (`FRESHELL_URL`, `FRESHELL_TOKEN`, the pane). A claude sidecar gets +//! the same through three pieces, all wired by `freshell-server` (the session +//! host wires none of them: a hosted lane keeps its own fixed recipe): +//! +//! - the endpoint ([`FreshellEndpoint`]): every sidecar's environment carries +//! `FRESHELL=1`, `FRESHELL_URL` and `FRESHELL_TOKEN` from the server's own +//! bound port and token, and none of another pane's ids; +//! - the recipe ([`ClaudeFreshellMcpProvider`]): asked once per sidecar launch, +//! so `settings.codingCli.mcpServer` applies to the next launch without a +//! restart. Only the command and its arguments travel in the `create` line +//! (`freshellMcp`); the SDK puts that config on the CLI's argv, so the token +//! never goes there; +//! - the pane ([`ClaudeFreshellContext`]): the `create` line's +//! `freshellContext`, merged by the sidecar into the CLI's environment, from +//! which the CLI hands it to the MCP child. + +use std::sync::{Arc, OnceLock}; + +use futures_util::future::BoxFuture; +use serde::Serialize; + +pub use freshell_platform::freshell_context_env::FreshellEndpoint; + +use crate::claude_launch::ClaudeLaunchSpec; + +/// The command that starts the Freshell MCP server for a sidecar's CLI. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ClaudeFreshellMcp { + pub command: String, + pub args: Vec, +} + +/// What the server's Freshell MCP setting and command resolver answer for one +/// sidecar launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaudeFreshellMcpResolution { + /// `settings.codingCli.mcpServer` is off. + Disabled, + /// The setting is on, but the server command could not be resolved (the + /// reason). The pane starts without the MCP server; it never fails over it. + Unavailable(String), + /// The setting is on and this is the command. + Resolved(ClaudeFreshellMcp), +} + +/// Asked once per sidecar launch for the Freshell MCP recipe. +pub type ClaudeFreshellMcpProvider = + Arc BoxFuture<'static, ClaudeFreshellMcpResolution> + Send + Sync>; + +/// The pane a session runs for: the `create` line's `freshellContext`, which +/// becomes `FRESHELL_TAB_ID` / `FRESHELL_PANE_ID` in the CLI's environment. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeFreshellContext { + #[serde(skip_serializing_if = "Option::is_none")] + pub tab_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pane_id: Option, +} + +impl ClaudeFreshellContext { + /// The context of `tab_id` and `pane_id`; `None` when neither names + /// anything (an empty id names nothing). + pub fn from_ids(tab_id: Option<&str>, pane_id: Option<&str>) -> Option { + let id = |value: Option<&str>| value.filter(|v| !v.is_empty()).map(str::to_string); + let context = Self { + tab_id: id(tab_id), + pane_id: id(pane_id), + }; + (context.tab_id.is_some() || context.pane_id.is_some()).then_some(context) + } +} + +/// A claude lane's Freshell MCP wiring: set once each, after construction +/// (the `set_identity_sink` pattern), shared by every clone of the lane. +#[derive(Clone, Default)] +pub(crate) struct ClaudeFreshellMcpWiring { + endpoint: Arc>, + provider: Arc>, +} + +impl ClaudeFreshellMcpWiring { + /// Install the endpoint; `false` when one already was. + pub(crate) fn set_endpoint(&self, endpoint: FreshellEndpoint) -> bool { + self.endpoint.set(endpoint).is_ok() + } + + /// Install the recipe provider; `false` when one already was. + pub(crate) fn set_provider(&self, provider: ClaudeFreshellMcpProvider) -> bool { + self.provider.set(provider).is_ok() + } + + /// The endpoint every sidecar's environment carries, once installed. + pub(crate) fn endpoint(&self) -> Option<&FreshellEndpoint> { + self.endpoint.get() + } + + /// `spec` with the Freshell MCP recipe this launch gets: none for a lane + /// that selected the hosted recipe or has no provider, else the provider's + /// answer, asked now. Logged per launch (`freshagent.claude.mcp_injection`, + /// and `freshagent.claude.mcp_unavailable` when the command could not be + /// resolved). + pub(crate) async fn launch(&self, mut spec: ClaudeLaunchSpec) -> ClaudeLaunchSpec { + spec.freshell_mcp = None; + if spec.hosted_mcp { + return spec; + } + let Some(provider) = self.provider.get() else { + return spec; + }; + let resolution = provider().await; + if let ClaudeFreshellMcpResolution::Unavailable(error) = &resolution { + tracing::warn!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.mcp_unavailable", + request_id = %spec.request_id, + session_type = %spec.session_type, + error = %error, + "the Freshell MCP server command could not be resolved; the pane starts without it" + ); + } + tracing::info!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.mcp_injection", + request_id = %spec.request_id, + session_type = %spec.session_type, + boot = spec.boot.kind(), + enabled = !matches!(resolution, ClaudeFreshellMcpResolution::Disabled), + resolved = matches!(resolution, ClaudeFreshellMcpResolution::Resolved(_)), + has_pane_context = spec.freshell_context.is_some(), + "the Freshell MCP selection for a claude sidecar launch" + ); + if let ClaudeFreshellMcpResolution::Resolved(recipe) = resolution { + spec.freshell_mcp = Some(recipe); + } + spec + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::claude_launch::ClaudeBoot; + use crate::identity_sink::FreshAgentSettings; + + fn recipe() -> ClaudeFreshellMcp { + ClaudeFreshellMcp { + command: "node".into(), + args: vec!["/srv/freshell-mcp/server.js".into()], + } + } + + fn spec(hosted_mcp: bool) -> ClaudeLaunchSpec { + ClaudeLaunchSpec::new( + "req", + "freshclaude", + ClaudeBoot::Fresh, + None, + &FreshAgentSettings::default(), + hosted_mcp, + ) + } + + fn answering(resolution: ClaudeFreshellMcpResolution) -> ClaudeFreshellMcpWiring { + let wiring = ClaudeFreshellMcpWiring::default(); + let provider: ClaudeFreshellMcpProvider = Arc::new(move || { + let resolution = resolution.clone(); + Box::pin(async move { resolution }) + }); + assert!(wiring.set_provider(provider)); + wiring + } + + #[tokio::test] + async fn a_launch_gets_the_recipe_the_provider_resolves_now() { + let launched = answering(ClaudeFreshellMcpResolution::Resolved(recipe())) + .launch(spec(false)) + .await; + assert_eq!(launched.freshell_mcp, Some(recipe())); + } + + #[tokio::test] + async fn a_disabled_or_unresolvable_recipe_launches_without_one() { + for resolution in [ + ClaudeFreshellMcpResolution::Disabled, + ClaudeFreshellMcpResolution::Unavailable("no tsx".into()), + ] { + let launched = answering(resolution.clone()).launch(spec(false)).await; + assert_eq!(launched.freshell_mcp, None, "{resolution:?}"); + } + } + + #[tokio::test] + async fn a_lane_without_a_provider_launches_without_a_recipe() { + let launched = ClaudeFreshellMcpWiring::default().launch(spec(false)).await; + assert_eq!(launched.freshell_mcp, None); + } + + /// The hosted lane keeps its fixed recipe and never gets the ordinary one, + /// even when a provider would answer one. + #[tokio::test] + async fn a_hosted_lane_never_gets_the_ordinary_recipe() { + let launched = answering(ClaudeFreshellMcpResolution::Resolved(recipe())) + .launch(spec(true)) + .await; + assert_eq!(launched.freshell_mcp, None); + } + + #[test] + fn a_pane_context_names_only_what_it_was_given() { + assert_eq!(ClaudeFreshellContext::from_ids(None, None), None); + assert_eq!(ClaudeFreshellContext::from_ids(Some(""), Some("")), None); + assert_eq!( + ClaudeFreshellContext::from_ids(Some("tab-1"), None), + Some(ClaudeFreshellContext { + tab_id: Some("tab-1".into()), + pane_id: None, + }) + ); + assert_eq!( + serde_json::to_value(ClaudeFreshellContext::from_ids(Some("t"), Some("p")).unwrap()) + .unwrap(), + serde_json::json!({ "tabId": "t", "paneId": "p" }) + ); + } + + #[test] + fn the_endpoint_and_provider_are_set_once() { + let wiring = ClaudeFreshellMcpWiring::default(); + assert!(wiring.endpoint().is_none()); + assert!(wiring.set_endpoint(FreshellEndpoint::local(4100, "tok"))); + assert!(!wiring.set_endpoint(FreshellEndpoint::local(4200, "other"))); + assert_eq!(wiring.endpoint().unwrap().url, "http://localhost:4100"); + } +} diff --git a/crates/freshell-freshagent/src/claude_launch.rs b/crates/freshell-freshagent/src/claude_launch.rs index 2d59850a2..e711f69c0 100644 --- a/crates/freshell-freshagent/src/claude_launch.rs +++ b/crates/freshell-freshagent/src/claude_launch.rs @@ -5,8 +5,8 @@ //! handoff resume, a rollback respawn) describes itself as one //! [`ClaudeLaunchSpec`], and [`ClaudeLaunchSpec::create_request`] is the only //! builder of the sidecar's `create` line. So no launch can drop a field another -//! carries: the pane's plugins and the hosted Freshell MCP reach the CLI on every -//! launch (G15). +//! carries: the pane's plugins, the hosted or ordinary Freshell MCP and the pane +//! context reach the CLI on every launch (G15, G19). //! //! The line names its stdio protocol ([`SIDECAR_PROTOCOL`]) and a [`ClaudeBoot`]: //! a fresh conversation, a resume, or a fork of a parent conversation at a point. @@ -20,6 +20,7 @@ use std::path::Path; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; +use crate::claude_freshell_mcp::{ClaudeFreshellContext, ClaudeFreshellMcp}; use crate::claude_snapshot::{LocateError, TranscriptLocation}; use crate::identity_sink::FreshAgentSettings; @@ -166,6 +167,12 @@ pub(crate) struct ClaudeLaunchSpec { /// The id a fresh or forked boot writes the conversation under (the /// session's own id when it boots from its genesis, design §4.2). pub(crate) session_id: Option, + /// The ordinary Freshell MCP recipe (G19), resolved when the sidecar is + /// launched (`claude_freshell_mcp`); never for a hosted lane. + pub(crate) freshell_mcp: Option, + /// The pane the session runs for (G19): a create's or an attach's pane, or + /// the replaced or forked session's. + pub(crate) freshell_context: Option, } impl ClaudeLaunchSpec { @@ -193,9 +200,17 @@ impl ClaudeLaunchSpec { hosted_mcp, model_selection: None, session_id: None, + freshell_mcp: None, + freshell_context: None, } } + /// This launch running for the pane `context` (G19). + pub(crate) fn with_freshell_context(mut self, context: Option) -> Self { + self.freshell_context = context; + self + } + /// This launch writing a fresh or forked conversation under `session_id`. pub(crate) fn with_session_id(mut self, session_id: Option) -> Self { self.session_id = session_id; @@ -210,9 +225,9 @@ impl ClaudeLaunchSpec { /// The sidecar `create` line. Every line carries the same keys, whatever /// the launch: `type`, `protocol`, `requestId`, `sessionType`, `boot`, and - /// `sessionId`, `cwd`, `model`, `permissionMode`, `effort`, `plugins` (each - /// `null` when unset) and `hostedMcp`; `modelSelection` only when the - /// launch has one. + /// `sessionId`, `cwd`, `model`, `permissionMode`, `effort`, `plugins`, + /// `freshellMcp`, `freshellContext` (each `null` when unset) and + /// `hostedMcp`; `modelSelection` only when the launch has one. pub(crate) fn create_request(&self) -> Value { let mut line = Map::new(); line.insert("type".into(), json!("create")); @@ -227,6 +242,8 @@ impl ClaudeLaunchSpec { line.insert("effort".into(), json!(self.effort)); line.insert("plugins".into(), json!(self.plugins)); line.insert("hostedMcp".into(), json!(self.hosted_mcp)); + line.insert("freshellMcp".into(), json!(self.freshell_mcp)); + line.insert("freshellContext".into(), json!(self.freshell_context)); if let Some(selection) = &self.model_selection { line.insert("modelSelection".into(), selection.clone()); } @@ -522,10 +539,71 @@ mod tests { "effort": "high", "plugins": ["/w/.claude/plugins/review"], "hostedMcp": true, + "freshellMcp": null, + "freshellContext": null, }) ); } + /// G19: whatever the boot, the line hands the sidecar the ordinary + /// Freshell MCP recipe and the pane context the launch carries (only the + /// command and its arguments, never an environment), and names both keys + /// when unset. + #[test] + fn every_boot_carries_the_freshell_mcp_recipe_and_the_pane_context() { + let recipe = ClaudeFreshellMcp { + command: "node".into(), + args: vec!["/srv/freshell-mcp/server.js".into()], + }; + for boot in [ + ClaudeBoot::Fresh, + ClaudeBoot::Resume { resume: "P".into() }, + ClaudeBoot::Fork { + resume: "P".into(), + at: Some("a1".into()), + drops_turn: None, + }, + ] { + let kind = boot.kind(); + let mut spec = ClaudeLaunchSpec::new( + "req", + "kilroy", + boot.clone(), + None, + &FreshAgentSettings::default(), + false, + ) + .with_freshell_context(ClaudeFreshellContext::from_ids( + Some("tab-1"), + Some("pane-1"), + )); + spec.freshell_mcp = Some(recipe.clone()); + let line = spec.create_request(); + assert_eq!( + line["freshellMcp"], + json!({ "command": "node", "args": ["/srv/freshell-mcp/server.js"] }), + "{kind}" + ); + assert_eq!( + line["freshellContext"], + json!({ "tabId": "tab-1", "paneId": "pane-1" }), + "{kind}" + ); + + let bare = ClaudeLaunchSpec::new( + "req", + "kilroy", + boot, + None, + &FreshAgentSettings::default(), + false, + ) + .create_request(); + assert_eq!(bare.get("freshellMcp"), Some(&Value::Null), "{kind}"); + assert_eq!(bare.get("freshellContext"), Some(&Value::Null), "{kind}"); + } + } + /// G15: whatever the boot, the line hands the sidecar the session's plugins /// and the lane's hosted MCP selection, and names both keys when unset. #[test] diff --git a/crates/freshell-freshagent/src/claude_spawn.rs b/crates/freshell-freshagent/src/claude_spawn.rs index ca5e6c699..f040da33f 100644 --- a/crates/freshell-freshagent/src/claude_spawn.rs +++ b/crates/freshell-freshagent/src/claude_spawn.rs @@ -170,6 +170,7 @@ pub struct ClaudeNodeCommand { ownership_tag: Option, closed_stdin: bool, complete_environment: Option>, + freshell_endpoint: Option, } impl ClaudeNodeCommand { @@ -201,6 +202,7 @@ impl ClaudeNodeCommand { ownership_tag: None, closed_stdin: false, complete_environment: None, + freshell_endpoint: None, } } @@ -234,6 +236,20 @@ impl ClaudeNodeCommand { self } + /// When `Some`, the child reaches this server through `endpoint` (G19): + /// it gets `FRESHELL=1`, `FRESHELL_URL` and `FRESHELL_TOKEN`, and none of + /// the pane-scoped names (`FRESHELL_TERMINAL_ID`, `FRESHELL_TAB_ID`, + /// `FRESHELL_PANE_ID`) it would otherwise inherit, for example from a + /// server that itself runs inside a Freshell terminal: its session's pane + /// arrives with the `create` line. `None` leaves those names alone. + pub fn with_freshell_endpoint( + mut self, + endpoint: Option<&freshell_platform::freshell_context_env::FreshellEndpoint>, + ) -> Self { + self.freshell_endpoint = endpoint.cloned(); + self + } + /// The child reads no stdin (the catalog probe); the default is a pipe. pub fn with_closed_stdin(mut self) -> Self { self.closed_stdin = true; @@ -278,6 +294,13 @@ impl ClaudeNodeCommand { command.env_clear(); command.envs(environment.iter().map(|(key, value)| (key, value))); } + if let Some(endpoint) = &self.freshell_endpoint { + use freshell_platform::freshell_context_env as context; + for name in context::FRESHELL_PANE_CONTEXT_ENV_VARS { + command.env_remove(name); + } + command.envs(context::freshell_context_env(endpoint, None, None, None)); + } if let Some(ownership_id) = &self.ownership_tag { command.env(CLAUDE_SIDECAR_OWNERSHIP_ENV, ownership_id); } @@ -1014,6 +1037,70 @@ mod tests { assert_eq!(String::from_utf8_lossy(&output.stdout), "HOME=/rig/home\0"); } + /// G19: a sidecar reaches this server through the endpoint it is given, + /// never through an inherited one, and inherits no other pane's ids (here + /// `env -0` stands in for node and prints what the child got). + #[cfg(unix)] + #[tokio::test] + async fn a_child_gets_this_servers_endpoint_and_no_other_panes_ids() { + let env = env_from(&[("FRESHELL_CLAUDE_NODE", "/usr/bin/env")]); + let inherited = [ + ("HOME".to_string(), "/rig/home".to_string()), + ( + "FRESHELL_URL".to_string(), + "http://localhost:3001".to_string(), + ), + ("FRESHELL_TOKEN".to_string(), "outer-token".to_string()), + ( + "FRESHELL_TERMINAL_ID".to_string(), + "outer-terminal".to_string(), + ), + ("FRESHELL_TAB_ID".to_string(), "outer-tab".to_string()), + ("FRESHELL_PANE_ID".to_string(), "outer-pane".to_string()), + ]; + let endpoint = + freshell_platform::freshell_context_env::FreshellEndpoint::local(4100, "this-token"); + let output = ClaudeNodeCommand::from_parts("-0", &env, None) + .with_complete_environment(Some(&inherited)) + .with_freshell_endpoint(Some(&endpoint)) + .with_closed_stdin() + .command() + .output() + .await + .expect("run env"); + assert!(output.status.success(), "{output:?}"); + let mut got: Vec = String::from_utf8_lossy(&output.stdout) + .split('\0') + .filter(|entry| !entry.is_empty()) + .map(str::to_string) + .collect(); + got.sort(); + assert_eq!( + got, + [ + "FRESHELL=1", + "FRESHELL_TOKEN=this-token", + "FRESHELL_URL=http://localhost:4100", + "HOME=/rig/home", + ] + ); + } + + /// Without an endpoint the child's Freshell names are left as inherited. + #[test] + fn a_child_without_an_endpoint_keeps_its_freshell_names() { + let env = env_from(&[]); + let inherited = [("FRESHELL_TAB_ID".to_string(), "tab".to_string())]; + let command = ClaudeNodeCommand::from_parts("/srv/sidecar/index.mjs", &env, None) + .with_complete_environment(Some(&inherited)) + .command(); + assert_eq!( + envs(&command).get("FRESHELL_TAB_ID"), + Some(&Some("tab".to_string())) + ); + assert!(!envs(&command).contains_key("FRESHELL_URL")); + } + #[test] fn the_run_as_wrapper_needs_both_ids() { let env = env_from(&[("FRESHELL_PROVIDER_RUN_AS_UID", "1234")]); diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 460b7d627..e096b4e33 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -38,6 +38,7 @@ pub mod claude; pub(crate) mod claude_dead_sessions; +pub mod claude_freshell_mcp; pub(crate) mod claude_handshake; pub(crate) mod claude_launch; pub(crate) mod claude_live_events; diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index c22847909..033ac285b 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -78,8 +78,17 @@ pub struct RigOptions { /// The session handoff route (`POST /api/sessions/handoff`) on the rig's /// server, driven by a runner over the same lanes (main.rs's wiring). pub handoff: bool, + /// The ordinary Freshell MCP wiring main.rs installs (G19): the claude lane + /// gets the rig server's own endpoint (its bound loopback port and + /// [`AUTH_TOKEN`]) and a recipe provider answering what this cell holds at + /// each sidecar launch (a test changes it to flip the setting). + pub freshell_mcp: Option, } +/// What the rig's Freshell MCP recipe provider answers, changeable mid-test. +pub type RigFreshellMcp = + Arc>; + pub struct ClaudeRig { pub kind: RigKind, /// `ws://127.0.0.1:/ws` of the rig's server (an ephemeral loopback port). @@ -1004,6 +1013,21 @@ async fn spawn_rig_server( .await .expect("bind an ephemeral loopback port"); let addr = listener.local_addr().expect("local addr"); + if let Some(resolution) = options.freshell_mcp.clone() { + // main.rs wires the same two pieces from its bound port and token. + assert!(lane.set_freshell_endpoint( + freshell_freshagent::claude_freshell_mcp::FreshellEndpoint { + url: format!("http://{addr}"), + token: AUTH_TOKEN.to_string(), + } + )); + let provider: freshell_freshagent::claude_freshell_mcp::ClaudeFreshellMcpProvider = + Arc::new(move || { + let answer = resolution.lock().expect("rig mcp resolution").clone(); + Box::pin(async move { answer }) + }); + assert!(lane.set_freshell_mcp_provider(provider)); + } let server = tokio::spawn(async move { let _ = axum::serve(listener, router).await; }); diff --git a/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs b/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs new file mode 100644 index 000000000..17bb47735 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs @@ -0,0 +1,391 @@ +//! G19 (api-hosted-ops plan §2.4): a freshclaude or kilroy pane's CLI gets the +//! ordinary Freshell MCP server, the way a terminal pane's CLI does. +//! +//! Every sidecar launch (create, attach-resume, handoff resume, undo/redo +//! respawn, fork) asks the lane's recipe provider, so the setting +//! (`settings.codingCli.mcpServer`) applies to the next launch without a +//! restart. The recipe travels in the `create` line as a command and its +//! arguments only; the endpoint and token are in the sidecar's environment, +//! which the server sets from its own bound port and token; the pane the +//! session runs for travels as `freshellContext` and lands in the CLI's +//! environment, from which the CLI hands all of it to the MCP child. +//! +//! Each test runs the real sidecar over the CLI simulator (RealSidecarSim), +//! whose ledger records the options of every `query()` call, including the +//! Freshell context values of the CLI environment and a digest of the token. + +mod common; + +use std::sync::Arc; + +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use common::AUTH_TOKEN; +use freshell_freshagent::claude_freshell_mcp::{ClaudeFreshellMcp, ClaudeFreshellMcpResolution}; + +/// The recipe the rig's provider resolves (the simulator records it as given). +fn recipe() -> ClaudeFreshellMcp { + ClaudeFreshellMcp { + command: "node".into(), + args: vec!["/srv/freshell/dist/tools/freshell-mcp/server.js".into()], + } +} + +fn resolved() -> ClaudeFreshellMcpResolution { + ClaudeFreshellMcpResolution::Resolved(recipe()) +} + +/// A RealSidecarSim rig wired the way main.rs wires the default server, with +/// a durable ledger (resumes read the session back) and the handoff route. +async fn rig_with( + resolution: ClaudeFreshellMcpResolution, + hosted: bool, +) -> ( + ClaudeRig, + Arc>, +) { + let cell = Arc::new(std::sync::Mutex::new(resolution)); + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + handoff: true, + hosted_claude: hosted, + env: vec![("KILROY_ENABLED".into(), "1".into())], + freshell_mcp: Some(Arc::clone(&cell)), + ..RigOptions::default() + }, + ) + .await; + (rig, cell) +} + +/// The options of the `query()` calls the simulator recorded, in order. +fn query_options(rig: &ClaudeRig) -> Vec { + rig.ledger_rows("query") + .into_iter() + .map(|row| row["options"].clone()) + .collect() +} + +/// The first recorded `query()` options matching `predicate`, once recorded. +async fn wait_for_query(rig: &ClaudeRig, what: &str, predicate: impl Fn(&Value) -> bool) -> Value { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + if let Some(options) = query_options(rig).into_iter().find(|o| predicate(o)) { + return options; + } + assert!( + tokio::time::Instant::now() < deadline, + "no query() call for {what}; recorded: {:?}", + query_options(rig) + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } +} + +/// The simulator's digest of a credential value (`query.mjs`). +fn digest(value: &str) -> String { + format!("{:x}", Sha256::digest(value.as_bytes()))[..12].to_string() +} + +/// The launch handed the CLI the ordinary recipe (command and arguments, no +/// environment) and an environment that reaches the rig's server for `pane`. +fn assert_mcp_for_pane(rig: &ClaudeRig, options: &Value, pane: Option<(&str, &str)>, launch: &str) { + assert_eq!( + options["mcpServers"], + json!({ "freshell": { "command": "node", "args": ["/srv/freshell/dist/tools/freshell-mcp/server.js"] } }), + "the {launch} hands the CLI the Freshell MCP recipe and nothing else: {options}" + ); + let mut context = json!({ "FRESHELL": "1", "FRESHELL_URL": rig.http_url }); + if let Some((tab, pane)) = pane { + context["FRESHELL_TAB_ID"] = json!(tab); + context["FRESHELL_PANE_ID"] = json!(pane); + } + assert_eq!( + options["envContext"], context, + "the {launch}'s CLI reaches this server for its pane: {options}" + ); + assert_eq!( + options["envDigests"]["FRESHELL_TOKEN"], + json!(digest(AUTH_TOKEN)), + "the {launch}'s CLI carries this server's token: {options}" + ); +} + +/// Create a session for `pane` (`tabId`, `paneId`); returns the session id. +async fn create_for_pane( + conn: &mut RigConnection, + request_id: &str, + session_type: &str, + pane: (&str, &str), +) -> String { + let created = conn + .request_create( + request_id, + json!({ "sessionType": session_type, "tabId": pane.0, "paneId": pane.1 }), + ) + .await; + assert_eq!(created["type"], "freshAgent.created", "{created}"); + created["sessionId"] + .as_str() + .expect("a session id") + .to_string() +} + +/// One finished turn per prompt, so the conversation is on disk; returns the +/// durable id the first turn reported. +async fn converse(conn: &mut RigConnection, session_id: &str, prompts: &[&str]) -> String { + for (n, prompt) in prompts.iter().enumerate() { + let results_before = conn.events_seen("freshAgent.result"); + conn.send_text(session_id, &format!("send-{n}"), prompt) + .await; + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while conn.events_seen("freshAgent.result") == results_before { + assert!( + tokio::time::Instant::now() < deadline, + "turn {n} never finished" + ); + conn.read_for(std::time::Duration::from_millis(50)).await; + } + } + conn.frames + .iter() + .find_map(|v| { + (v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.session.init" + && v["event"]["sessionId"] == session_id) + .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) + .flatten() + }) + .expect("the first turn reported the durable id") +} + +/// The session's sidecar dies on its own: the session is untracked, the state a +/// reload or a handoff resumes from. +async fn kill_the_sidecar(rig: &ClaudeRig, conn: &mut RigConnection) { + let pid = rig.ledger_rows("boot")[0]["pid"] + .as_i64() + .expect("the boot row names the sidecar's pid") as i32; + // SAFETY: a plain signal to the sidecar this rig spawned. + assert_eq!(unsafe { libc::kill(pid, libc::SIGKILL) }, 0); + conn.wait_for("the sidecar's exit", |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.error" + && v["event"]["code"] == "SIDECAR_EXITED" + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_freshclaude_and_a_kilroy_create_hand_the_cli_the_freshell_mcp_for_their_pane() { + let (rig, _) = rig_with(resolved(), false).await; + let mut conn = rig.connect().await; + create_for_pane( + &mut conn, + "create-claude", + "freshclaude", + ("tab-1", "pane-1"), + ) + .await; + create_for_pane(&mut conn, "create-kilroy", "kilroy", ("tab-2", "pane-2")).await; + + let options = query_options(&rig); + assert_eq!(options.len(), 2, "two sidecars booted: {options:?}"); + assert_mcp_for_pane( + &rig, + &options[0], + Some(("tab-1", "pane-1")), + "freshclaude create", + ); + assert_mcp_for_pane( + &rig, + &options[1], + Some(("tab-2", "pane-2")), + "kilroy create", + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_setting_applies_to_the_next_launch_without_a_restart() { + let (rig, setting) = rig_with(ClaudeFreshellMcpResolution::Disabled, false).await; + let mut conn = rig.connect().await; + create_for_pane(&mut conn, "create-off", "freshclaude", ("tab-1", "pane-1")).await; + *setting.lock().unwrap() = resolved(); + create_for_pane(&mut conn, "create-on", "freshclaude", ("tab-1", "pane-2")).await; + *setting.lock().unwrap() = + ClaudeFreshellMcpResolution::Unavailable("no MCP server build".into()); + create_for_pane( + &mut conn, + "create-unresolved", + "freshclaude", + ("tab-1", "pane-3"), + ) + .await; + + let options = query_options(&rig); + assert_eq!(options.len(), 3, "three sidecars booted: {options:?}"); + assert!( + options[0].get("mcpServers").is_none(), + "with the setting off the CLI gets no MCP server: {}", + options[0] + ); + assert_mcp_for_pane( + &rig, + &options[1], + Some(("tab-1", "pane-2")), + "create after the setting turned on", + ); + assert!( + options[2].get("mcpServers").is_none(), + "a command that cannot be resolved starts the pane without it: {}", + options[2] + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_hosted_lane_keeps_its_fixed_recipe_and_never_gets_the_ordinary_one() { + let (rig, _) = rig_with(resolved(), true).await; + let mut conn = rig.connect().await; + create_for_pane( + &mut conn, + "create-hosted", + "freshclaude", + ("tab-1", "pane-1"), + ) + .await; + + let options = query_options(&rig); + assert_eq!( + options[0]["mcpServers"], + json!({ "freshell": { "command": "node", "args": ["/opt/freshell-mcp/server.js"] } }), + "{}", + options[0] + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_attach_resume_hands_the_cli_the_freshell_mcp_for_the_attaching_pane() { + let (rig, _) = rig_with(resolved(), false).await; + let mut conn = rig.connect().await; + let session_id = + create_for_pane(&mut conn, "create-1", "freshclaude", ("tab-1", "pane-1")).await; + let durable = converse(&mut conn, &session_id, &["first prompt"]).await; + kill_the_sidecar(&rig, &mut conn).await; + + // The pane, moved to another tab meanwhile, attaches again (a reload). + conn.send(json!({ + "type": "freshAgent.attach", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "sessionRef": { "provider": "claude", "sessionId": durable }, + "tabId": "tab-9", + "paneId": "pane-1", + })) + .await; + let resumed = wait_for_query(&rig, "the attach-resume", |o| o["resume"] == durable).await; + assert_mcp_for_pane(&rig, &resumed, Some(("tab-9", "pane-1")), "attach-resume"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_handoff_resume_hands_the_cli_the_freshell_mcp() { + let (rig, _) = rig_with(resolved(), false).await; + let mut conn = rig.connect().await; + let session_id = + create_for_pane(&mut conn, "create-1", "freshclaude", ("tab-1", "pane-1")).await; + let durable = converse(&mut conn, &session_id, &["first prompt"]).await; + kill_the_sidecar(&rig, &mut conn).await; + + let (status, answer) = rig + .post_json( + "/api/sessions/handoff", + &json!({ + "provider": "claude", + "sessionId": durable, + "targetKind": "fresh-agent", + "sessionType": "freshclaude", + "cwd": rig.workspace.to_str().expect("utf-8 path"), + }), + ) + .await; + assert_eq!(status, 200, "the handoff resumes the session: {answer}"); + // A handoff names no pane: the pane that adopts the session is not known + // yet, so the CLI reaches the server without a pane of its own. + let resumed = wait_for_query(&rig, "the handoff resume", |o| o["resume"] == durable).await; + assert_mcp_for_pane(&rig, &resumed, None, "handoff resume"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_undo_respawn_runs_for_the_same_pane() { + let (rig, _) = rig_with(resolved(), false).await; + let mut conn = rig.connect().await; + let session_id = + create_for_pane(&mut conn, "create-1", "freshclaude", ("tab-1", "pane-1")).await; + let durable = converse(&mut conn, &session_id, &["first prompt", "second prompt"]).await; + + let (epoch, generation) = conn + .owner_pair(&durable) + .expect("the session's owner record was broadcast"); + conn.send(json!({ + "type": "freshAgent.undo", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "requestId": "undo-1", + "mode": "step", + "observedEpoch": epoch, + "observedGeneration": generation, + })) + .await; + let ack = conn + .wait_for("the undo's ack", |v| { + v["type"] == "freshAgent.event" && v["event"]["requestId"] == "undo-1" + }) + .await; + assert_eq!(ack["event"]["type"], "freshAgent.rolledBack", "{ack}"); + let undone = wait_for_query(&rig, "the undo's respawn", |o| { + o["forkSession"] == true && o["resume"] == durable + }) + .await; + assert_mcp_for_pane(&rig, &undone, Some(("tab-1", "pane-1")), "undo respawn"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_fork_child_runs_for_the_forking_pane() { + let (rig, _) = rig_with(resolved(), false).await; + let mut conn = rig.connect().await; + let session_id = + create_for_pane(&mut conn, "create-1", "freshclaude", ("tab-1", "pane-1")).await; + let durable = converse(&mut conn, &session_id, &["first prompt"]).await; + + let mut fork = json!({ + "type": "freshAgent.fork", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "requestId": "fork-1", + "tabId": "tab-1", + }); + if let Some((epoch, generation)) = conn.owner_pair(&durable) { + fork["observedEpoch"] = json!(epoch); + fork["observedGeneration"] = json!(generation); + } + conn.send(fork).await; + let forked = conn + .wait_for("the fork's answer", |v| { + (v["type"] == "freshAgent.forked" && v["requestId"] == "fork-1") + || (v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.error" + && v["event"]["sessionId"] == session_id.as_str()) + }) + .await; + assert_eq!(forked["type"], "freshAgent.forked", "{forked}"); + let child = forked["sessionId"] + .as_str() + .expect("forked names the child"); + let options = wait_for_query(&rig, "the fork's child", |o| o["sessionId"] == child).await; + assert_mcp_for_pane(&rig, &options, Some(("tab-1", "pane-1")), "fork child"); +} From 2383d12bebe2545cc7c39a1b7b992b52c852cc78 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:07:44 -0700 Subject: [PATCH 06/21] feat(server): wire the Freshell MCP for freshclaude/kilroy from the live setting main.rs hands the claude lane this server's own endpoint (its port and auth token) and a recipe provider that reads settings.codingCli.mcpServer from the live settings tree at every sidecar launch and resolves the same Freshell MCP command the terminal lanes use, rendered for this host's CLI. ServerSettings::agent_mcp_server_enabled is the one gate the terminal lanes will read next (#446). A PATCH of the setting turns the recipe off and back on with no restart. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-protocol/src/settings.rs | 10 + .../src/claude_freshell_mcp.rs | 178 ++++++++++++++++++ crates/freshell-server/src/main.rs | 17 ++ 3 files changed, 205 insertions(+) create mode 100644 crates/freshell-server/src/claude_freshell_mcp.rs diff --git a/crates/freshell-protocol/src/settings.rs b/crates/freshell-protocol/src/settings.rs index 37bdc3605..cf9ac6ae2 100644 --- a/crates/freshell-protocol/src/settings.rs +++ b/crates/freshell-protocol/src/settings.rs @@ -151,3 +151,13 @@ pub struct ServerSettings { #[serde(skip_serializing_if = "Option::is_none")] pub default_cwd: Option, } + +impl ServerSettings { + /// Whether coding agents get the Freshell MCP server + /// (`settings.codingCli.mcpServer`, the Settings page's "Agent MCP server"). + /// The one gate every lane reads: freshclaude/kilroy sidecars today, the + /// terminal CLIs next (GitHub #446). + pub fn agent_mcp_server_enabled(&self) -> bool { + self.coding_cli.mcp_server + } +} diff --git a/crates/freshell-server/src/claude_freshell_mcp.rs b/crates/freshell-server/src/claude_freshell_mcp.rs new file mode 100644 index 000000000..ee026526c --- /dev/null +++ b/crates/freshell-server/src/claude_freshell_mcp.rs @@ -0,0 +1,178 @@ +//! The default server's Freshell MCP wiring for freshclaude and kilroy panes +//! (G19, api-hosted-ops plan §2.4): the recipe provider the claude lane asks at +//! every sidecar launch. +//! +//! It reads `settings.codingCli.mcpServer` from the live settings tree each +//! time, so a change from the Settings page applies to the next pane without a +//! restart, and resolves the same Freshell MCP server command the terminal +//! lanes inject (`build_mcp_server_command`, rendered for this host's own CLI). +//! The session host never installs it: a hosted lane keeps its fixed recipe. + +use std::sync::Arc; + +use freshell_freshagent::claude_freshell_mcp::{ + ClaudeFreshellMcp, ClaudeFreshellMcpProvider, ClaudeFreshellMcpResolution, +}; +use freshell_platform::cli_launch::ProviderTarget; +use freshell_platform::mcp_inject::{build_mcp_server_command, McpRuntime}; +use freshell_protocol::settings::ServerSettings; +use tokio::sync::RwLock; + +/// The CLI a freshclaude sidecar starts runs on this host, natively. +pub(crate) fn host_native_target() -> ProviderTarget { + if cfg!(windows) { + ProviderTarget::Windows + } else { + ProviderTarget::Unix + } +} + +/// The provider: `Disabled` while the setting is off, otherwise the resolved +/// command, or `Unavailable` with the reason it could not be resolved. +pub(crate) fn claude_mcp_provider( + settings: Arc>, + runtime: Arc, + target: ProviderTarget, +) -> ClaudeFreshellMcpProvider { + Arc::new(move || { + let settings = Arc::clone(&settings); + let runtime = Arc::clone(&runtime); + Box::pin(async move { + if !settings.read().await.agent_mcp_server_enabled() { + return ClaudeFreshellMcpResolution::Disabled; + } + // Resolving can run a short path-conversion helper (WSL); keep it + // off the async workers. + let resolved = tokio::task::spawn_blocking(move || { + build_mcp_server_command(runtime.as_ref(), target) + }) + .await; + match resolved { + Ok(Ok((command, args))) => { + ClaudeFreshellMcpResolution::Resolved(ClaudeFreshellMcp { command, args }) + } + Ok(Err(error)) => ClaudeFreshellMcpResolution::Unavailable(error.to_string()), + Err(join) => ClaudeFreshellMcpResolution::Unavailable(join.to_string()), + } + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings_store::{router, SettingsRouterState, SettingsStore}; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use freshell_platform::mcp_inject::{McpInjectError, McpServerArg}; + use serde_json::json; + use std::path::PathBuf; + use tower::ServiceExt; + + /// An MCP runtime whose server command is a fixed path (or fails). + struct FixedRuntime(Option<&'static str>); + + impl McpRuntime for FixedRuntime { + fn tmp_dir(&self) -> PathBuf { + std::env::temp_dir() + } + fn is_wsl_environment(&self) -> bool { + false + } + fn convert_to_windows_path(&self, path: &str) -> String { + path.to_string() + } + fn server_command_args(&self) -> Result, McpInjectError> { + match self.0 { + Some(entry) => Ok(vec![McpServerArg::Path(entry.to_string())]), + None => Err(McpInjectError { + message: "Unable to resolve MCP dependency \"tsx\".".into(), + }), + } + } + } + + /// The real settings route over a real store in a throwaway home. + fn settings_route(home: &std::path::Path) -> (axum::Router, SettingsStore) { + std::fs::create_dir_all(home.join(".freshell")).unwrap(); + let store = SettingsStore::load(Some(home), vec!["claude".into()]); + let auth_token = Arc::new("tok".to_string()); + let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(16).0); + let state = SettingsRouterState { + store: store.clone(), + auth_token: Arc::clone(&auth_token), + broadcast_tx: Arc::clone(&broadcast_tx), + fresh_codex: freshell_freshagent::FreshCodexState::new( + auth_token, + broadcast_tx, + json!({}), + ), + registry: freshell_terminal::TerminalRegistry::new(), + ai_key: crate::ai_title::AiKeyCell::default(), + }; + (router(state), store) + } + + async fn patch(route: &axum::Router, body: serde_json::Value) { + let response = route + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/api/settings") + .header("x-auth-token", "tok") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + fn resolved(entry: &str) -> ClaudeFreshellMcpResolution { + ClaudeFreshellMcpResolution::Resolved(ClaudeFreshellMcp { + command: "node".into(), + args: vec![entry.into()], + }) + } + + /// The toggle on the Settings page reaches the next sidecar launch with no + /// restart: the provider reads the live settings tree the route updates. + #[tokio::test] + async fn the_settings_toggle_turns_the_recipe_off_and_on_without_a_restart() { + let home = tempfile::tempdir().unwrap(); + let (route, store) = settings_route(home.path()); + let provider = claude_mcp_provider( + store.shared_settings_lock(), + Arc::new(FixedRuntime(Some("/srv/freshell-mcp/server.js"))), + ProviderTarget::Unix, + ); + + // On by default. + assert_eq!(provider().await, resolved("/srv/freshell-mcp/server.js")); + patch(&route, json!({ "codingCli": { "mcpServer": false } })).await; + assert_eq!(provider().await, ClaudeFreshellMcpResolution::Disabled); + patch(&route, json!({ "codingCli": { "mcpServer": true } })).await; + assert_eq!(provider().await, resolved("/srv/freshell-mcp/server.js")); + } + + /// A command that cannot be resolved is reported with its reason, so the + /// lane logs it and starts the pane without the server. + #[tokio::test] + async fn an_unresolvable_command_is_unavailable_with_its_reason() { + let home = tempfile::tempdir().unwrap(); + let (_, store) = settings_route(home.path()); + let provider = claude_mcp_provider( + store.shared_settings_lock(), + Arc::new(FixedRuntime(None)), + ProviderTarget::Unix, + ); + match provider().await { + ClaudeFreshellMcpResolution::Unavailable(reason) => { + assert!(reason.contains("tsx"), "{reason}") + } + other => panic!("expected Unavailable, got {other:?}"), + } + } +} diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index 23ecef2ed..c084bafbf 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -23,6 +23,7 @@ mod auto_title; mod auto_title_sweep; mod boot; mod checkpoints; +mod claude_freshell_mcp; mod diag; mod existence; mod existence_by_id; @@ -1058,6 +1059,22 @@ async fn main() -> ExitCode { fresh_claude_state .set_checkpoint_event_persister(Arc::clone(&checkpoint_event_persister)) .expect("Claude checkpoint event persister is installed once"); + // G19: freshclaude/kilroy panes get the Freshell MCP server the way + // terminal panes do. Every sidecar's CLI reaches THIS server (its own port + // and token, never values inherited from the environment it was started + // in), and the recipe is resolved at each launch from the live + // `codingCli.mcpServer` setting, so the toggle needs no restart. + fresh_claude_state.set_freshell_endpoint( + freshell_freshagent::claude_freshell_mcp::FreshellEndpoint::local( + port, + auth_token.as_str(), + ), + ); + fresh_claude_state.set_freshell_mcp_provider(claude_freshell_mcp::claude_mcp_provider( + settings_store.shared_settings_lock(), + Arc::new(freshell_platform::mcp_inject::RealMcpRuntime), + claude_freshell_mcp::host_native_target(), + )); // Task 12 (D8 for fresh agents): the ONE server-wide per-sessionRef create/resume // lease map, shared by every fresh-agent runtime (keys are provider-namespaced). From 01a12f5e242da9fe748ca02137b335025204aba9 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:11:22 -0700 Subject: [PATCH 07/21] feat(client): fresh-agent create and attach name their pane freshAgent.create now carries the pane's paneId beside its tabId, and freshAgent.attach carries tabId and paneId, so a freshclaude/kilroy sidecar launched or resumed for the pane runs its Freshell MCP for that pane (G19). The two tests that pin an attach frame exactly now expect the pane fields. Co-Authored-By: Claude Opus 5.5 --- src/components/fresh-agent/FreshAgentView.tsx | 13 +++- .../fresh-agent/FreshAgentView.test.tsx | 67 +++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index a2639a112..9d57742eb 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -616,6 +616,7 @@ function captureAttachmentAttempt( function buildFreshAgentAttachMessage( content: FreshAgentPaneContent, + pane: { tabId: string, paneId: string }, cwd?: string, observedFence?: ObservedOwnerFence, ) { @@ -627,6 +628,10 @@ function buildFreshAgentAttachMessage( provider: content.provider, ...(sessionRef ? { sessionRef } : {}), ...(cwd ? { cwd } : {}), + // G19: an attach that resumes the session hands the agent's Freshell MCP + // this pane as its context. + tabId: pane.tabId, + paneId: pane.paneId, ...(observedFence ? { observedEpoch: observedFence.epoch, observedGeneration: observedFence.generation } : {}), @@ -1780,9 +1785,9 @@ export function FreshAgentView({ if (attachmentAttemptKey(state, content, attachDecisionSerialRef.current) !== attempt.key) return false if (isLifecycleStartSuperseded(state, 'fresh-agent', content, attempt.fence)) return false const cwd = getFreshOpenCodeRouteCwd(attempt.content, { sessionCwd: freshOpenCodeRouteCwdRef.current }) - sendFreshAgentMessage(buildFreshAgentAttachMessage(attempt.content, cwd, attempt.fence)) + sendFreshAgentMessage(buildFreshAgentAttachMessage(attempt.content, { tabId, paneId }, cwd, attempt.fence)) return true - }, [appStore, sendFreshAgentMessage]) + }, [appStore, paneId, sendFreshAgentMessage, tabId]) // kata b8ke: the opened-as-CLI-elsewhere attach action — THIS pane adopts // the committed terminal owner (keeping its sessionRef + createRequestId, @@ -2025,6 +2030,8 @@ export function FreshAgentView({ // D8 (restore-open-sessions-only): the server composes the ledger row's // tabKey as `deviceId:tabId` from the connection identity + this field. tabId, + // G19: with tabId, the pane the agent's Freshell MCP acts for. + paneId, ...(namingHandle ? { namingHandle } : {}), // kata b8ke delayed-request fence (round-2 review: the fence is the // (epoch, generation) PAIR from the runtime-owner record observed when @@ -2034,7 +2041,7 @@ export function FreshAgentView({ ? { observedEpoch: observedFence.epoch, observedGeneration: observedFence.generation } : {}), } as const - }, [providerDefaults, tabId]) + }, [paneId, providerDefaults, tabId]) const startNewConversation = useCallback(async () => { const current = paneContentRef.current diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index 93c5d6553..9a4f000e6 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -2417,6 +2417,67 @@ describe('FreshAgentView', () => { })) }) + // G19: the agent's Freshell MCP acts for the pane it runs in, so a create + // names that pane (FRESHELL_TAB_ID / FRESHELL_PANE_ID in the agent's CLI). + it('names the pane a freshclaude create runs for', async () => { + const store = createStore() + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-pane-context', + status: 'creating', + }, + })) + + render( + + + , + ) + + await waitFor(() => expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(1)) + expect(sentFreshAgentMessages('freshAgent.create')[0]).toMatchObject({ + requestId: 'req-pane-context', + tabId: 'tab-1', + paneId: 'pane-1', + }) + }) + + // G19: an attach can resume the session's sidecar; the resumed agent's + // Freshell MCP acts for the pane that attached. + it('names the attaching pane on a kilroy attach', async () => { + const store = createStore() + const locator = { sessionId: CLAUDE_THREAD_ID, sessionType: 'kilroy' as const, provider: 'claude' as const } + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + ...locator, + createRequestId: 'req-attach-context', + sessionRef: { provider: 'claude', sessionId: CLAUDE_THREAD_ID }, + status: 'idle', + }, + })) + + render( + + + , + ) + + await waitFor(() => expect(sentFreshAgentMessages('freshAgent.attach')).toHaveLength(1)) + expect(sentFreshAgentMessages('freshAgent.attach')[0]).toMatchObject({ + ...locator, + tabId: 'tab-1', + paneId: 'pane-1', + }) + }) + it('acquires a session id for a new non-Claude fresh-agent pane after freshAgent.created', async () => { const store = createStore() store.dispatch(initLayout({ @@ -3346,6 +3407,8 @@ describe('FreshAgentView', () => { provider: 'opencode', sessionRef: { provider: 'opencode', sessionId: 'ses_attach_route' }, cwd: '/repo/route-aware', + tabId: 'tab-1', + paneId: 'pane-1', }) }) expect(reconnectHandler).toBeTypeOf('function') @@ -3363,6 +3426,8 @@ describe('FreshAgentView', () => { provider: 'opencode', sessionRef: { provider: 'opencode', sessionId: 'ses_attach_route' }, cwd: '/repo/route-aware', + tabId: 'tab-1', + paneId: 'pane-1', }) }) }) @@ -7165,6 +7230,8 @@ describe('FreshAgentView', () => { sessionType: 'freshcodex', provider: 'codex', sessionRef: { provider: 'codex', sessionId: 'thread-refresh' }, + tabId: 'tab-1', + paneId: 'pane-1', }) }) await waitFor(() => { From fcdf611d5e1924d489cab8c18e5b02121e9dd4bd Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:13:50 -0700 Subject: [PATCH 08/21] test(claude-sidecar): pin the Freshell MCP environment and argv contract on the pinned CLI The real sidecar over the pinned SDK 0.3.237 / CLI 2.1.237 and a local provider, launched the way the Rust server launches a freshclaude pane: the CLI's argv carries the MCP config but never the token, the stdio MCP child receives FRESHELL, FRESHELL_URL, the token and the pane context (and no terminal id), the server connects (its tool is listed) and a model call of its tool returns what it saw. Co-Authored-By: Claude Opus 5.5 --- .../claude-freshell-mcp-probe-server.mjs | 59 ++++++ .../providers/claude-freshell-mcp.mjs | 185 ++++++++++++++++++ .../providers/claude-freshell-mcp.test.ts | 72 +++++++ 3 files changed, 316 insertions(+) create mode 100644 test/integration/providers/claude-freshell-mcp-probe-server.mjs create mode 100644 test/integration/providers/claude-freshell-mcp.mjs create mode 100644 test/integration/providers/claude-freshell-mcp.test.ts diff --git a/test/integration/providers/claude-freshell-mcp-probe-server.mjs b/test/integration/providers/claude-freshell-mcp-probe-server.mjs new file mode 100644 index 000000000..a3299a9a9 --- /dev/null +++ b/test/integration/providers/claude-freshell-mcp-probe-server.mjs @@ -0,0 +1,59 @@ +// A minimal stdio MCP server standing in for the Freshell MCP server +// (tools/freshell-mcp/server.ts) in the G19 contract and simulator tests. +// +// node claude-freshell-mcp-probe-server.mjs +// +// On start it writes what the Freshell MCP client would read from its +// environment to : the Freshell context names, with the token as +// a sha256 digest (never its value). Its one tool, `context`, answers with the +// same report as text, so a model turn can show what the server saw. +import { createHash } from 'node:crypto' +import { writeFileSync } from 'node:fs' +import readline from 'node:readline' + +const PROBE_TOOL = 'context' + +const report = { + pid: process.pid, + FRESHELL: process.env.FRESHELL ?? null, + FRESHELL_URL: process.env.FRESHELL_URL ?? null, + FRESHELL_TOKEN_SHA256: process.env.FRESHELL_TOKEN + ? createHash('sha256').update(process.env.FRESHELL_TOKEN).digest('hex') + : null, + FRESHELL_TAB_ID: process.env.FRESHELL_TAB_ID ?? null, + FRESHELL_PANE_ID: process.env.FRESHELL_PANE_ID ?? null, + FRESHELL_TERMINAL_ID: process.env.FRESHELL_TERMINAL_ID ?? null, +} +if (process.argv[2]) writeFileSync(process.argv[2], `${JSON.stringify(report)}\n`) + +const lines = readline.createInterface({ input: process.stdin }) +const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`) + +lines.on('line', (line) => { + let message + try { message = JSON.parse(line) } catch { return } + if (message.method === 'initialize') { + send({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: message.params?.protocolVersion ?? '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'freshell-probe', version: '1.0.0' }, + }, + }) + } else if (message.method === 'tools/list') { + send({ + jsonrpc: '2.0', + id: message.id, + result: { + tools: [{ name: PROBE_TOOL, description: 'Reports the Freshell context this server received.', inputSchema: { type: 'object', properties: {} } }], + }, + }) + } else if (message.method === 'tools/call') { + send({ jsonrpc: '2.0', id: message.id, result: { content: [{ type: 'text', text: JSON.stringify(report) }] } }) + } else if (message.id !== undefined) { + send({ jsonrpc: '2.0', id: message.id, result: {} }) + } +}) +lines.on('close', () => process.exit(0)) diff --git a/test/integration/providers/claude-freshell-mcp.mjs b/test/integration/providers/claude-freshell-mcp.mjs new file mode 100644 index 000000000..d0736d117 --- /dev/null +++ b/test/integration/providers/claude-freshell-mcp.mjs @@ -0,0 +1,185 @@ +// Contract harness (audit G19, api-hosted-ops plan §2.4/§3.2): the REAL Claude sidecar +// (crates/freshell-claude-sidecar/index.mjs) over the pinned SDK and its bundled native CLI +// (resolvePinnedClaude), in the shared throwaway fixture (createClaudeFixture), against an +// isolated local provider endpoint (the real Anthropic API is never contacted). +// +// The sidecar is launched the way the Rust server launches it for a freshclaude pane: its +// environment carries FRESHELL=1, FRESHELL_URL and a random FRESHELL_TOKEN, and its create +// line names the ordinary Freshell MCP recipe (`freshellMcp`, a probe stdio MCP server that +// stands in for tools/freshell-mcp) and the pane (`freshellContext`). One turn asks the +// model to call the probe's tool. The receipt records what the CLI and the MCP child got: +// - the CLI's argv carries the MCP config but never the token; +// - the MCP child started and received the endpoint, the token and the pane context; +// - the CLI listed the probe's tool (the server connected) and the tool call returned the +// probe's report. +import fs from 'node:fs/promises' +import path from 'node:path' +import { spawn } from 'node:child_process' +import { createInterface } from 'node:readline' +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { createClaudeFixture, FAKE_MODEL, resolvePinnedClaude, startLocalProvider } from './claude-local-provider.mjs' + +const repoRoot = process.argv[2] +if (!repoRoot) throw new Error('repository root argument is required') + +const here = path.dirname(fileURLToPath(import.meta.url)) +const PROBE_SERVER = path.join(here, 'claude-freshell-mcp-probe-server.mjs') +const PROBE_TOOL = 'mcp__freshell__context' +const PROMPT = 'call the freshell probe' +const sidecarDir = path.join(repoRoot, 'crates/freshell-claude-sidecar') + +const pinned = await resolvePinnedClaude(repoRoot) +const fixture = await createClaudeFixture({ prefix: 'freshell-claude-freshell-mcp-' }) +const token = `fresh-token-${randomBytes(16).toString('hex')}` +const freshellUrl = 'http://127.0.0.1:9/freshell-probe-endpoint' +const context = { tabId: 'tab-g19', paneId: 'pane-g19' } +const reportFile = path.join(fixture.root, 'probe-report.json') +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) + +let provider +let sidecar +let failureStage = 'initialization' +const receipt = { + verdict: 'FAIL', + sdkVersion: pinned.sdkVersion, + bundledCliVersion: pinned.cliVersion, + cliArgvHasMcpConfig: false, + cliArgvHasToken: null, + probe: null, + probeToolListed: false, + toolResultReport: null, + toolNames: null, +} + +try { + failureStage = 'local provider' + // The model calls the probe's tool for the scripted prompt, then ends the turn. + provider = await startLocalProvider({ + respond: ({ promptText, afterTool, tools }) => { + if (afterTool) return { blocks: [{ type: 'text', text: 'done' }] } + if (promptText.includes(PROMPT) && tools.includes(PROBE_TOOL)) { + return { blocks: [{ type: 'tool_use', name: PROBE_TOOL, input: {} }] } + } + return undefined + }, + }) + + failureStage = 'sidecar start' + sidecar = spawn(process.execPath, [path.join(sidecarDir, 'index.mjs')], { + cwd: sidecarDir, + env: fixture.env(provider.url, { + CLAUDE_CMD: pinned.nativeCli, + FRESHELL_CLAUDE_ONECLI_API_KEY: '1', + // What the Rust server sets on every sidecar (claude_spawn::with_freshell_endpoint). + FRESHELL: '1', + FRESHELL_URL: freshellUrl, + FRESHELL_TOKEN: token, + }), + stdio: ['pipe', 'pipe', 'pipe'], + }) + let stderr = '' + sidecar.stderr.setEncoding('utf8').on('data', (chunk) => { stderr = (stderr + chunk).slice(-4000) }) + sidecar.stdin.on('error', () => {}) + const frames = [] + const waiters = [] + createInterface({ input: sidecar.stdout }).on('line', (line) => { + let frame + try { frame = JSON.parse(line) } catch { return } + frames.push(frame) + for (const waiter of [...waiters]) if (waiter.match(frame)) { waiters.splice(waiters.indexOf(waiter), 1); waiter.resolve(frame) } + }) + const waitFor = (match, ms, what) => new Promise((resolve, reject) => { + const existing = frames.find(match) + if (existing) return resolve(existing) + const timer = setTimeout(() => reject(new Error(`timed out waiting for ${what}; stderr=${stderr.slice(-800)}`)), ms) + waiters.push({ match, resolve: frame => { clearTimeout(timer); resolve(frame) } }) + }) + const write = message => sidecar.stdin.write(`${JSON.stringify(message)}\n`) + + failureStage = 'create' + const requestId = randomUUID() + write({ + type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId, cwd: fixture.cwd, model: FAKE_MODEL, + permissionMode: 'bypassPermissions', hostedMcp: false, + freshellMcp: { command: process.execPath, args: [PROBE_SERVER, reportFile] }, + freshellContext: context, + }) + const created = await waitFor(frame => frame.requestId === requestId && (frame.type === 'created' || frame.type === 'create.failed'), 45_000, 'created') + if (created.type !== 'created') throw new Error(`the sidecar refused the create: ${created.message}`) + + failureStage = 'cli argv' + const argv = (await fs.readFile(`/proc/${created.cliPid}/cmdline`, 'utf8')).split('\0') + const mcpConfigAt = argv.indexOf('--mcp-config') + receipt.cliArgvHasMcpConfig = mcpConfigAt >= 0 && argv[mcpConfigAt + 1]?.includes(PROBE_SERVER) === true + receipt.cliArgvHasToken = argv.some(arg => arg.includes(token)) + + failureStage = 'turn' + write({ type: 'send', sessionId: created.sessionId, text: PROMPT }) + const init = await waitFor(frame => frame.type === 'sdk.session.init', 45_000, 'the turn init') + // The sidecar forwards the CLI's tool list as `{ name }` objects. + receipt.toolNames = (init.tools ?? []).map(tool => (typeof tool === 'string' ? tool : tool?.name)).filter(name => String(name).startsWith('mcp__')) + receipt.probeToolListed = receipt.toolNames.includes(PROBE_TOOL) + await waitFor(frame => frame.type === 'sdk.result', 45_000, 'the turn result') + // The CLI's tool result for the probe call (the user message carrying the tool_result + // block reaches the sidecar as a transcript announcement only, so read the transcript). + const transcripts = await fixture.transcripts() + for (const relative of transcripts) { + const text = await fs.readFile(path.join(fixture.home, '.claude', relative), 'utf8') + for (const line of text.split('\n').filter(Boolean)) { + const row = JSON.parse(line) + const blocks = Array.isArray(row.message?.content) ? row.message.content : [] + for (const block of blocks) { + if (block?.type !== 'tool_result') continue + const body = Array.isArray(block.content) ? block.content.map(part => part?.text ?? '').join('') : String(block.content ?? '') + try { receipt.toolResultReport = JSON.parse(body) } catch { receipt.toolResultReport = { unparsed: body.slice(0, 200) } } + } + } + } + + failureStage = 'probe report' + for (let waited = 0; waited < 10_000 && receipt.probe === null; waited += 50) { + try { receipt.probe = JSON.parse(await fs.readFile(reportFile, 'utf8')) } catch { await sleep(50) } + } + + const tokenDigest = createHash('sha256').update(token).digest('hex') + const probeOk = receipt.probe !== null + && receipt.probe.FRESHELL === '1' + && receipt.probe.FRESHELL_URL === freshellUrl + && receipt.probe.FRESHELL_TOKEN_SHA256 === tokenDigest + && receipt.probe.FRESHELL_TAB_ID === context.tabId + && receipt.probe.FRESHELL_PANE_ID === context.paneId + receipt.probeSawContext = probeOk + receipt.toolCallReturnedReport = receipt.toolResultReport?.FRESHELL_TOKEN_SHA256 === tokenDigest + if (receipt.probe) receipt.probe = { ...receipt.probe, FRESHELL_TOKEN_SHA256: receipt.probe.FRESHELL_TOKEN_SHA256 === tokenDigest ? 'matches' : 'differs' } + if (receipt.toolResultReport?.FRESHELL_TOKEN_SHA256) receipt.toolResultReport = { ...receipt.toolResultReport, FRESHELL_TOKEN_SHA256: receipt.toolResultReport.FRESHELL_TOKEN_SHA256 === tokenDigest ? 'matches' : 'differs' } + if (receipt.cliArgvHasMcpConfig && receipt.cliArgvHasToken === false && probeOk && receipt.probeToolListed && receipt.toolCallReturnedReport) { + receipt.verdict = 'PASS' + } + + failureStage = 'shutdown' + write({ type: 'shutdown' }) + sidecar.stdin.end() + await new Promise(resolve => { + if (sidecar.exitCode !== null) return resolve() + const timer = setTimeout(resolve, 5_000) + sidecar.once('exit', () => { clearTimeout(timer); resolve() }) + }) +} catch (error) { + receipt.failureStage = failureStage + process.stderr.write(`${JSON.stringify({ + severity: 'error', + component: 'claude-freshell-mcp-contract', + stage: failureStage, + name: error?.name ?? 'Error', + message: String(error?.message ?? 'unknown error').replaceAll(token, '').slice(0, 800), + })}\n`) +} finally { + if (sidecar && sidecar.exitCode === null) sidecar.kill('SIGKILL') + if (receipt.probe?.pid) { try { process.kill(receipt.probe.pid, 'SIGKILL') } catch {} } + await provider?.close() + await fixture.cleanup() +} + +process.stdout.write(`${JSON.stringify(receipt).replaceAll(token, '')}\n`) +if (receipt.verdict !== 'PASS') process.exitCode = 1 diff --git a/test/integration/providers/claude-freshell-mcp.test.ts b/test/integration/providers/claude-freshell-mcp.test.ts new file mode 100644 index 000000000..617899eb9 --- /dev/null +++ b/test/integration/providers/claude-freshell-mcp.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment node + +import { spawn, type ChildProcess } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { PINNED_CLI_VERSION, PINNED_SDK_VERSION } from './claude-local-provider.mjs' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = path.resolve(__dirname, '../../..') +const HARNESS = path.join(__dirname, 'claude-freshell-mcp.mjs') +const children = new Set() + +afterEach(() => { + for (const child of children) child.kill('SIGKILL') + children.clear() +}) + +// G19 (api-hosted-ops plan §2.4, §3.2): a freshclaude pane's Freshell MCP +// server reaches the server through the inherited environment, never the +// CLI's argv. Against the pinned SDK and CLI: the real sidecar hands the CLI +// the recipe the Rust server resolved; the CLI starts the stdio MCP child with +// the endpoint, the token and the pane context the sidecar merged in; the +// server connects (its tool is listed and callable); and the token is nowhere +// on the CLI's command line, where the MCP config itself travels. +describe('Freshell MCP in a Claude sidecar against the pinned CLI and a local provider', () => { + it('connects the MCP child with the endpoint, token and pane in its environment and keeps the token off the CLI argv', async () => { + const child = spawn(process.execPath, [HARNESS, REPO_ROOT], { + cwd: REPO_ROOT, + env: { PATH: process.env.PATH ?? '/usr/bin:/bin', LANG: 'C.UTF-8' }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + children.add(child) + let stdout = '' + let stderr = '' + child.stdout!.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk }) + child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk }) + const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + const timer = setTimeout(() => { + child.kill('SIGTERM') + reject(new Error(`Freshell MCP contract timed out; stderr=${stderr.slice(-2000)}`)) + }, 150_000) + timer.unref() + child.once('error', (error) => { clearTimeout(timer); reject(error) }) + child.once('exit', (code, signal) => { clearTimeout(timer); resolve({ code, signal }) }) + }) + children.delete(child) + + const lines = stdout.trim().split('\n').filter(Boolean) + expect(lines, stderr).toHaveLength(1) + const receipt = JSON.parse(lines[0]!) as Record + expect(receipt, `${lines[0]}\n${stderr}`).toMatchObject({ + sdkVersion: PINNED_SDK_VERSION, + bundledCliVersion: PINNED_CLI_VERSION, + cliArgvHasMcpConfig: true, + cliArgvHasToken: false, + probeToolListed: true, + probeSawContext: true, + toolCallReturnedReport: true, + probe: { + FRESHELL: '1', + FRESHELL_URL: 'http://127.0.0.1:9/freshell-probe-endpoint', + FRESHELL_TOKEN_SHA256: 'matches', + FRESHELL_TAB_ID: 'tab-g19', + FRESHELL_PANE_ID: 'pane-g19', + FRESHELL_TERMINAL_ID: null, + }, + verdict: 'PASS', + }) + expect(exit, stderr).toEqual({ code: 0, signal: null }) + }, 160_000) +}) From 9637631dacfb3ab25b9656551e7eea4b0acc8b0f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:21:39 -0700 Subject: [PATCH 09/21] test(claude-cli-sim): run stdio MCP servers the way the pinned CLI does The simulator now starts each stdio MCP server in options.mcpServers with the SDK env (not the simulator's own environment), reports it connected or failed, lists its tools in init once a turn has waited for the servers to settle, calls a listed tool over tools/call (heartbeats while it runs), and answers a call of an MCP tool no server lists with the CLI's "No such tool available" error before the message's trailing stream. A new conformance scenario (mcp, marker MCP:call=) and the G19 fact test pin this on the pinned CLI 2.1.237: the server gets the CLI's environment, its tool is listed and called, and an unknown MCP tool is an error result. The full contract and simulator files stay green. Co-Authored-By: Claude Opus 5.5 --- test/fixtures/claude-cli-sim/mcp.mjs | 175 ++++++++++++++++++ test/fixtures/claude-cli-sim/program.mjs | 6 +- .../claude-cli-sim/prompt-markers.mjs | 7 +- test/fixtures/claude-cli-sim/query.mjs | 20 +- test/fixtures/claude-cli-sim/tools.mjs | 40 ++++ .../providers/claude-cli-contract.test.ts | 30 +++ .../providers/claude-cli-scenarios.mjs | 55 ++++++ .../providers/claude-local-provider.mjs | 2 + 8 files changed, 330 insertions(+), 5 deletions(-) create mode 100644 test/fixtures/claude-cli-sim/mcp.mjs diff --git a/test/fixtures/claude-cli-sim/mcp.mjs b/test/fixtures/claude-cli-sim/mcp.mjs new file mode 100644 index 000000000..e24752e77 --- /dev/null +++ b/test/fixtures/claude-cli-sim/mcp.mjs @@ -0,0 +1,175 @@ +// The stdio MCP servers of a simulated CLI session, the way the pinned CLI +// runs them (conformance scenario `mcp`, claude-cli-scenarios.mjs): +// - each stdio server in `options.mcpServers` starts when the session does, +// with the CLI's environment (the SDK `env`) plus the server's own `env`; +// - a server is `connected` once it answered `initialize` and `tools/list`, +// else `failed`; a model turn waits until every server has settled, then +// its system/init lists the servers and their tools (`mcp____`); +// - a call of a listed tool is a `tools/call`: its content is the tool +// result (an error result when the server says so or goes away); +// - a call of an MCP tool no connected server lists is the CLI's +// "No such tool available" error result; +// - the servers stop with the session. +// Servers of other transports (http, sse, sdk) are reported `failed`: the +// simulator only runs stdio servers. + +import { spawn } from 'node:child_process' +import { createInterface } from 'node:readline' + +/** How long a server may take to answer initialize and tools/list (the CLI's MCP_TIMEOUT). */ +const DEFAULT_CONNECT_TIMEOUT_MS = 30_000 +const PROTOCOL_VERSION = '2025-06-18' + +/** `mcp____` split into its server and tool names, or null. */ +export function parseMcpToolName(name) { + const match = /^mcp__(.+?)__(.+)$/.exec(String(name)) + return match ? { server: match[1], tool: match[2] } : null +} + +/** The CLI's error result for a tool the model called that no server offers. */ +export function noSuchToolResult(name) { + const message = `No such tool available: ${name}` + return { content: `Error: ${message}`, toolUseResult: `Error: ${message}` } +} + +class StdioServer { + constructor(name, config, { env, cwd, timeoutMs }) { + this.name = name + this.status = 'pending' + this.tools = [] + this.pending = new Map() + this.nextId = 1 + this.child = spawn(config.command, Array.isArray(config.args) ? config.args : [], { + cwd, + env: { ...env, ...(config.env ?? {}) }, + stdio: ['pipe', 'pipe', 'ignore'], + }) + this.child.on('error', () => this.fail()) + this.child.stdin.on('error', () => {}) + this.child.once('exit', () => this.fail()) + createInterface({ input: this.child.stdout }).on('line', (line) => this.onLine(line)) + this.ready = this.connect(timeoutMs) + } + + async connect(timeoutMs) { + let timer + const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('MCP server connection timed out')), timeoutMs) }) + try { + await Promise.race([this.handshake(), timeout]) + this.status = 'connected' + } catch { + this.fail() + } finally { + clearTimeout(timer) + } + } + + async handshake() { + await this.request('initialize', { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'claude-code', version: 'simulated' }, + }) + this.notify('notifications/initialized') + const listed = await this.request('tools/list', {}) + this.tools = (listed?.tools ?? []).map((tool) => String(tool?.name)).filter(Boolean) + } + + onLine(line) { + let message + try { message = JSON.parse(line) } catch { return } + if (message?.id === undefined || !this.pending.has(message.id)) return + const { resolve, reject } = this.pending.get(message.id) + this.pending.delete(message.id) + if (message.error) reject(new Error(String(message.error.message ?? 'MCP error'))) + else resolve(message.result) + } + + request(method, params) { + if (this.status === 'failed') return Promise.reject(new Error(`MCP server "${this.name}" is not connected`)) + const id = this.nextId++ + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }) + this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`) + }) + } + + notify(method) { + this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method })}\n`) + } + + fail() { + this.status = this.status === 'stopped' ? 'stopped' : 'failed' + for (const { reject } of this.pending.values()) reject(new Error(`MCP server "${this.name}" closed`)) + this.pending.clear() + } + + stop() { + this.status = 'stopped' + this.fail() + try { this.child.stdin.end() } catch { /* already gone */ } + try { this.child.kill('SIGTERM') } catch { /* already gone */ } + } +} + +export class McpServers { + /** Start every configured server with the CLI's environment `env`. */ + constructor(configs, { env, cwd }) { + const timeoutMs = Number(env.MCP_TIMEOUT) > 0 ? Number(env.MCP_TIMEOUT) : DEFAULT_CONNECT_TIMEOUT_MS + this.servers = new Map() + this.unsupported = [] + for (const [name, config] of Object.entries(configs ?? {})) { + if ((config?.type ?? 'stdio') === 'stdio' && typeof config?.command === 'string') { + this.servers.set(name, new StdioServer(name, config, { env, cwd, timeoutMs })) + } else { + this.unsupported.push(name) + } + } + } + + /** Resolves once every server has connected or failed. */ + settled() { + return Promise.all([...this.servers.values()].map((server) => server.ready)) + } + + /** The init message's `mcp_servers`. */ + statuses() { + return [ + ...[...this.servers.values()].map((server) => ({ name: server.name, status: server.status === 'connected' ? 'connected' : server.status === 'pending' ? 'pending' : 'failed' })), + ...this.unsupported.map((name) => ({ name, status: 'failed' })), + ] + } + + /** The qualified names of every connected server's tools. */ + toolNames() { + return [...this.servers.values()] + .filter((server) => server.status === 'connected') + .flatMap((server) => server.tools.map((tool) => `mcp__${server.name}__${tool}`)) + } + + /** Whether a connected server lists the qualified tool `name`. */ + offers(name) { + return this.toolNames().includes(name) + } + + /** + * Call the qualified tool `name`: `{ content, isError }` from the server, or + * an error result when the call fails. + */ + async call(name, input) { + const parsed = parseMcpToolName(name) + const server = parsed && this.servers.get(parsed.server) + if (!server || server.status !== 'connected') return { content: noSuchToolResult(name).content, isError: true } + try { + const result = await server.request('tools/call', { name: parsed.tool, arguments: input ?? {} }) + const content = Array.isArray(result?.content) ? result.content : [] + return { content, isError: result?.isError === true } + } catch (error) { + return { content: `Error: ${error?.message ?? error}`, isError: true } + } + } + + stop() { + for (const server of this.servers.values()) server.stop() + } +} diff --git a/test/fixtures/claude-cli-sim/program.mjs b/test/fixtures/claude-cli-sim/program.mjs index cc5a808b9..18786745b 100644 --- a/test/fixtures/claude-cli-sim/program.mjs +++ b/test/fixtures/claude-cli-sim/program.mjs @@ -18,7 +18,11 @@ // tool { name, input, id?, seconds?, output?, isError?, inputStreamMs? } // a tool call and its result; with inputStreamMs // the model streams the call's input for that long -// (a long Write) before the call completes +// (a long Write) before the call completes. An +// MCP tool (`mcp____`) is called on +// the session's stdio server, and its answer is the +// result; a tool no connected server lists is the +// CLI's "No such tool available" error (mcp.mjs) // approval { tool, input, id?, suggestions?, blockedPath?, decisionReason? } // a tool call that asks canUseTool, with these fields; under // bypassPermissions (where the pinned CLI asks for no tool diff --git a/test/fixtures/claude-cli-sim/prompt-markers.mjs b/test/fixtures/claude-cli-sim/prompt-markers.mjs index c462c89df..5ea9d0b30 100644 --- a/test/fixtures/claude-cli-sim/prompt-markers.mjs +++ b/test/fixtures/claude-cli-sim/prompt-markers.mjs @@ -15,6 +15,8 @@ // (the CLI retries each), then answers as for no marker // HANG the model request never completes // MCP:hang a call to the hung stdio MCP tool (mcp__hang__wait) +// MCP:call= a call of the MCP tool (qualified, mcp____) +// with no arguments, then a text reply // AGENT:async a background Agent; its completion starts a CLI-initiated turn // PLAN:exit an ExitPlanMode tool call carrying PLAN_TEXT // @@ -35,10 +37,11 @@ export const PLAN_TEXT = '# Plan\n\n1. Edit the file\n2. Run the tests' const TOOL_RE = /\bTOOL:sleep=(\d+(?:\.\d+)?)(?:,timeout=(\d+))?/ const APIERR_RE = /\bAPIERR:(\d{3})\b/ const RETRY_RE = /\bRETRY:(\d{3})x(\d+)\b/ +const MCP_CALL_RE = /\bMCP:call=(mcp__[A-Za-z0-9_-]+)/ /** * @param {string} text the text of the last user message - * @returns {{kind:'text'}|{kind:'slow',chunks:number,delayMs:number}|{kind:'tool',command:string,seconds:number,timeoutMs?:number}|{kind:'api_error',status:number}|{kind:'retry',status:number,attempts:number}|{kind:'hang'}|{kind:'mcp_hang'}|{kind:'agent_async'}|{kind:'exit_plan'}} + * @returns {{kind:'text'}|{kind:'slow',chunks:number,delayMs:number}|{kind:'tool',command:string,seconds:number,timeoutMs?:number}|{kind:'api_error',status:number}|{kind:'retry',status:number,attempts:number}|{kind:'hang'}|{kind:'mcp_hang'}|{kind:'mcp_call',tool:string}|{kind:'agent_async'}|{kind:'exit_plan'}} */ export function parsePromptMarkers(text) { const value = String(text ?? '') @@ -47,6 +50,8 @@ export function parsePromptMarkers(text) { const retry = RETRY_RE.exec(value) if (retry) return { kind: 'retry', status: Number(retry[1]), attempts: Number(retry[2]) } if (/\bMCP:hang\b/.test(value)) return { kind: 'mcp_hang' } + const mcpCall = MCP_CALL_RE.exec(value) + if (mcpCall) return { kind: 'mcp_call', tool: mcpCall[1] } if (/\bAGENT:async\b/.test(value)) return { kind: 'agent_async' } if (/\bPLAN:exit\b/.test(value)) return { kind: 'exit_plan' } if (/\bTOOL:touch\b/.test(value)) return { kind: 'tool', seconds: 0, command: `touch ${TOUCH_FILE}` } diff --git a/test/fixtures/claude-cli-sim/query.mjs b/test/fixtures/claude-cli-sim/query.mjs index 2a7104080..74be93e76 100644 --- a/test/fixtures/claude-cli-sim/query.mjs +++ b/test/fixtures/claude-cli-sim/query.mjs @@ -20,7 +20,9 @@ // - tool heartbeats every 30 s, Bash task promotion and the // BASH_MAX_TIMEOUT_MS cap, background agents and the CLI-initiated turn // their completion starts, API errors, canUseTool with suggestions and -// ExitPlanMode. +// ExitPlanMode; +// - stdio MCP servers started with the CLI's environment, listed once +// connected, and called for their tools (mcp.mjs). // // The model's side of each turn comes from prompt markers // (prompt-markers.mjs, shared with the real-CLI loopback provider) or from a @@ -84,6 +86,7 @@ import { usage, } from './sdk-shapes.mjs' import { toolCall } from './tools.mjs' +import { McpServers } from './mcp.mjs' import { claudeConfigDir, CLI_VERSION, @@ -127,6 +130,7 @@ function markerSteps(text) { case 'retry': return [{ kind: 'api_retry', data: { status: marker.status, attempts: marker.attempts } }, { kind: 'text', data: { text: 'ok' } }] case 'hang': return [{ kind: 'hang', data: {} }] case 'mcp_hang': return [{ kind: 'tool', data: { name: HANG_MCP_QUALIFIED_TOOL, input: {}, hang: true } }] + case 'mcp_call': return [{ kind: 'tool', data: { name: marker.tool, input: {} } }] case 'agent_async': return [{ kind: 'agent', data: {} }] case 'exit_plan': return [{ kind: 'exit_plan', data: { plan: PLAN_TEXT } }] default: return [{ kind: 'text', data: { text: 'ok' } }] @@ -225,6 +229,9 @@ class SimulatedCli { constructor({ prompt, options = {} }) { this.options = options this.env = { ...process.env, ...(options.env ?? {}) } + // What the CLI process itself (and so its MCP servers) gets: the SDK's + // `env` option in place of this process's environment, as the SDK does. + this.cliEnv = options.env ?? process.env this.scale = Math.max(0, Number(this.env.FRESHELL_CLAUDE_SIM_TIME_SCALE ?? 1) || 0) this.channel = createChannel() this.program = new ProgramEngine(loadProgram(this.env)) @@ -255,6 +262,8 @@ class SimulatedCli { this.sessionId = options.forkSession ? (options.sessionId ?? randomUUID()) : (options.resume ?? options.sessionId ?? randomUUID()) this.spawnCli() + // The CLI starts its stdio MCP servers with its own environment (mcp.mjs). + this.mcp = new McpServers(options.mcpServers, { env: this.cliEnv, cwd: this.cwd }) this.background(this.pumpInput(prompt)) this.background(this.boot()) @@ -380,6 +389,7 @@ class SimulatedCli { for (const timer of this.timers) clearTimeout(timer) this.timers.clear() this.turn?.abort() + this.mcp?.stop() this.init.reject(error ?? new Error('Query closed before the CLI booted')) this.channel.end(error) if (!this.cliExited) { @@ -474,7 +484,7 @@ class SimulatedCli { } systemInit() { - const mcpServers = Object.keys(this.options.mcpServers ?? {}).map((name) => ({ name, status: 'connected' })) + const mcpServers = this.mcp.statuses() const plugins = (this.options.plugins ?? []).map((plugin) => ({ name: path.basename(plugin.path ?? String(plugin)), path: plugin.path ?? String(plugin) })) return { type: 'system', @@ -485,6 +495,7 @@ class SimulatedCli { ...(this.env.FRESHELL_CLAUDE_SIM_TERMINAL_COMMANDS === undefined ? {} : { terminal_slash_commands: this.env.FRESHELL_CLAUDE_SIM_TERMINAL_COMMANDS.split(',').filter(Boolean) }), + tools: [...CATALOG.init.tools, ...this.mcp.toolNames()], mcp_servers: mcpServers, plugins, model: this.model, @@ -591,6 +602,8 @@ class SimulatedCli { async runModelTurn(turn) { const { cmd } = turn await this.sleep(T.startedToInit) + // A turn's request waits for the MCP servers to connect (or fail). + await this.mcp.settled() this.emit(this.systemInit()) await this.sleep(T.initToStatus) this.emit({ type: 'system', subtype: 'status', status: 'requesting' }) @@ -621,6 +634,7 @@ class SimulatedCli { // origin task-notification: no prompt of its own, and no SDK message. this.transcript.append({ type: 'user', message: { role: 'user', content: taskNotificationXml(task) }, origin: { kind: 'task-notification' } }) this.transcript.append({ type: 'attachment', attachment: { type: 'total_tokens_reminder', text: '15000000 tokens left' } }) + await this.mcp.settled() this.emit(this.systemInit()) await this.sleep(T.initToStatus) this.emit({ type: 'system', subtype: 'status', status: 'requesting' }) @@ -1014,7 +1028,7 @@ class SimulatedCli { } async mcpServerStatus() { - return this.control(() => Object.keys(this.options.mcpServers ?? {}).map((name) => ({ name, status: 'connected' }))) + return this.control(() => this.mcp.statuses()) } async initializationResult() { diff --git a/test/fixtures/claude-cli-sim/tools.mjs b/test/fixtures/claude-cli-sim/tools.mjs index a5270bc9c..d05a6c460 100644 --- a/test/fixtures/claude-cli-sim/tools.mjs +++ b/test/fixtures/claude-cli-sim/tools.mjs @@ -7,6 +7,7 @@ import path from 'node:path' import { randomUUID } from 'node:crypto' import { PLAN_TEXT } from './prompt-markers.mjs' import { READ_ONLY_COMMANDS, T } from './sdk-shapes.mjs' +import { noSuchToolResult } from './mcp.mjs' /** Whether the pinned CLI would ask canUseTool for this call in the current permission mode. */ function needsPermission(cli, name, input) { @@ -131,6 +132,17 @@ export async function toolCall(cli, turn, step) { if (step.kind === 'agent') return launchAgent(cli, turn, toolUseId, input) + // An MCP tool no connected server lists does not exist for the CLI: it + // answers at once, before any permission check and before the rest of the + // model's message has streamed. (A scripted hang stands in for a hung + // server's call, configured or not.) + if (name.startsWith('mcp__') && data.hang !== true && !cli.mcp.offers(name)) { + const missing = noSuchToolResult(name) + cli.toolResult(toolUseId, missing.content, { isError: true, toolUseResult: missing.toolUseResult }) + cli.trailingStream('tool_use') + return 'done' + } + if (step.kind === 'approval' || step.kind === 'question' || needsPermission(cli, name, input)) { cli.emitState('requires_action') const decision = await askPermission(cli, turn, name, input, toolUseId, data) @@ -156,9 +168,37 @@ export async function toolCall(cli, turn, step) { cli.toolResult(toolUseId, `User has answered your questions: ${JSON.stringify(input.answers ?? {})}`, { toolUseResult: { questions: input.questions, answers: input.answers ?? {} } }) return 'done' } + if (name.startsWith('mcp__') && data.hang !== true) return executeMcpTool(cli, turn, toolUseId, name, input) return executeTool(cli, turn, toolUseId, name, input, data) } +/** + * Call a connected server's tool: its result is the tool result. A running + * call sends a heartbeat every 30 s like any tool; sends pushed meanwhile are + * absorbed into this turn (T10). + */ +async function executeMcpTool(cli, turn, toolUseId, name, input) { + turn.absorbing = true + const call = cli.mcp.call(name, input) + let beats = 0 + for (;;) { + const next = (beats + 1) * T.heartbeat + const step = await Promise.race([ + call.then(() => 'settled'), + cli.wait(next - beats * T.heartbeat, turn).then((aborted) => (aborted ? 'aborted' : 'beat')), + ]) + if (step !== 'beat') break + cli.emit({ type: 'tool_progress', tool_use_id: `${toolUseId}-heartbeat-${beats}`, tool_name: name, parent_tool_use_id: toolUseId, elapsed_time_seconds: Math.floor(next / 1000), heartbeat: true }) + beats += 1 + } + turn.absorbing = false + if (turn.aborted) return cli.abortTool(turn, toolUseId) + const { content, isError } = await call + cli.toolResult(toolUseId, content, { isError, toolUseResult: content }) + startAbsorbed(cli, turn) + return 'done' +} + /** Run a tool; sends pushed meanwhile are absorbed into this turn (T10). */ async function executeTool(cli, turn, toolUseId, name, input, data) { turn.absorbing = true diff --git a/test/integration/providers/claude-cli-contract.test.ts b/test/integration/providers/claude-cli-contract.test.ts index 2b24df958..95fb1cfad 100644 --- a/test/integration/providers/claude-cli-contract.test.ts +++ b/test/integration/providers/claude-cli-contract.test.ts @@ -453,6 +453,36 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p expect(cliTurn).toEqual(['lifecycle u1 completed', 'idle', 'task_notification', 'running', 'init', 'result', 'idle']) expect(messagesOf(r, 'S').filter((m) => m.type === 'command_lifecycle').every((m) => m.command_uuid === 'u1')).toBe(true) }, SCENARIO_TIMEOUT) + + it('G19: a stdio MCP server starts with the CLI\'s environment and its tools are listed and called; an MCP tool no server offers is an error result', async () => { + const r = await real('mcp') + const environment = { + FRESHELL: '1', + FRESHELL_URL: 'http://127.0.0.1:9/probe', + FRESHELL_TOKEN_SHA256: expect.stringMatching(/^[0-9a-f]{64}$/), + FRESHELL_TAB_ID: 'tab-s', + FRESHELL_PANE_ID: 'pane-s', + FRESHELL_TERMINAL_ID: null, + } + expect(r.observations.serverEnvironment).toEqual(environment) + expect(r.observations.connected).toEqual({ + mcpServers: [{ name: 'freshell', status: 'connected' }], + mcpTools: ['mcp__freshell__context'], + toolResults: [{ isError: false, content: ['text'], text: environment, toolUseResult: ['text'] }], + }) + expect(r.observations.missing).toEqual({ + mcpServers: [], + mcpTools: [], + toolResults: [{ + isError: true, + content: 'string', + text: 'Error: No such tool available: mcp__freshell__freshell', + toolUseResult: 'string', + }], + }) + expect(results(r, 'S').map((m) => m.subtype)).toEqual(['success']) + expect(results(r, 'M').map((m) => m.subtype)).toEqual(['success']) + }, SCENARIO_TIMEOUT) }) describeSimulatorConformance(real) diff --git a/test/integration/providers/claude-cli-scenarios.mjs b/test/integration/providers/claude-cli-scenarios.mjs index 6f36cc1aa..12d1c8297 100644 --- a/test/integration/providers/claude-cli-scenarios.mjs +++ b/test/integration/providers/claude-cli-scenarios.mjs @@ -30,6 +30,9 @@ import { } from './claude-local-provider.mjs' import { HANG_MCP_SERVER_NAME } from '../../fixtures/claude-cli-sim/prompt-markers.mjs' +/** The stdio MCP server the `mcp` scenario connects (it stands in for tools/freshell-mcp). */ +const FRESHELL_MCP_PROBE_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'claude-freshell-mcp-probe-server.mjs') + export const SIM_QUERY_MODULE = path.join(DEFAULT_REPO_ROOT, 'test/fixtures/claude-cli-sim/query.mjs') const SESSION_STATE_ENV = { CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: '1' } const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -323,6 +326,32 @@ async function sha256(file) { return createHash('sha256').update(await fs.readFile(file)).digest('hex') } +/** + * What a turn that called an MCP tool showed: the servers and MCP tools its + * init listed, and each tool result (its error flag and text; a JSON text + * without the reporting process's pid). + */ +function mcpTurnShape(session) { + const init = session.messages((m) => m.type === 'system' && m.subtype === 'init')[0] + const results = session.messages((m) => m.type === 'user' && Array.isArray(m.message?.content)) + .flatMap((m) => m.message.content.filter((block) => block?.type === 'tool_result').map((block) => { + const text = Array.isArray(block.content) ? block.content.map((part) => part?.text ?? '').join('') : String(block.content ?? '') + let parsed + try { parsed = JSON.parse(text); delete parsed.pid } catch { parsed = text } + return { + isError: block.is_error === true, + content: Array.isArray(block.content) ? block.content.map((part) => part?.type) : typeof block.content, + text: parsed, + toolUseResult: Array.isArray(m.tool_use_result) ? m.tool_use_result.map((part) => part?.type) : typeof m.tool_use_result, + } + })) + return { + mcpServers: init?.mcp_servers ?? null, + mcpTools: (init?.tools ?? []).filter((name) => String(name).startsWith('mcp__')), + toolResults: results, + } +} + /** The chain entries of a transcript (lines that carry a uuid), in file order, without their payloads. */ function transcriptShape(lines) { return (lines ?? []).filter((line) => typeof line.uuid === 'string').map((line) => ({ @@ -671,6 +700,32 @@ const SCENARIOS = { await Promise.all([mcp.close(), bash.close()]) }, + /** + * G19: the CLI starts a stdio MCP server with its own environment, lists the + * server's tools once it connected, and returns a tool call's text result; + * a call of an MCP tool no configured server offers is an error result. + */ + async mcp(ctx) { + const report = path.join(ctx.fixture.root, 'mcp-probe-report.json') + const s = ctx.open('S', { + options: { mcpServers: { freshell: { type: 'stdio', command: process.execPath, args: [FRESHELL_MCP_PROBE_SERVER, report] } } }, + env: { FRESHELL: '1', FRESHELL_URL: 'http://127.0.0.1:9/probe', FRESHELL_TOKEN: 'probe-token', FRESHELL_TAB_ID: 'tab-s', FRESHELL_PANE_ID: 'pane-s' }, + }) + s.push('MCP:call=mcp__freshell__context', 'u1') + await s.waitForResults(1, 30_000) + ctx.observe('connected', mcpTurnShape(s)) + const seen = JSON.parse(await fs.readFile(report, 'utf8')) + delete seen.pid + ctx.observe('serverEnvironment', seen) + await s.close() + + const m = ctx.open('M') + m.push('MCP:call=mcp__freshell__freshell', 'u2') + await m.waitForResults(1, 30_000) + ctx.observe('missing', mcpTurnShape(m)) + await m.close() + }, + /** T18: an API error ends the turn with a success-subtype error result. */ async 'api-error'(ctx) { const X = ctx.uuid('X') diff --git a/test/integration/providers/claude-local-provider.mjs b/test/integration/providers/claude-local-provider.mjs index 73290be35..27fe87d05 100644 --- a/test/integration/providers/claude-local-provider.mjs +++ b/test/integration/providers/claude-local-provider.mjs @@ -374,6 +374,8 @@ export async function startLocalProvider({ replyText = 'ok', respond } = {}) { toolUse('Bash', { command: marker.command, description: 'local provider command', ...(marker.timeoutMs ? { timeout: marker.timeoutMs } : {}) }) } else if (marker.kind === 'mcp_hang') { toolUse(HANG_MCP_QUALIFIED_TOOL, {}) + } else if (marker.kind === 'mcp_call' && !afterTool) { + toolUse(marker.tool, {}) } else if (marker.kind === 'exit_plan') { toolUse('ExitPlanMode', { plan: PLAN_TEXT }) } else if (marker.kind === 'agent_async') { From e376dacdac22b8d2356ac714b4b2d6ce0c50a909 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:24:30 -0700 Subject: [PATCH 10/21] test(claude-cli-sim): a program's text step can repeat the last tool result A text emission with echoToolResult repeats what the turn's last tool call returned (after an optional text prefix), so a scripted model can show what an MCP tool answered. Pinned by a simulator test that calls a probe stdio MCP server through a program's tool step. Co-Authored-By: Claude Opus 5.5 --- test/fixtures/claude-cli-sim/program.mjs | 5 ++++- test/fixtures/claude-cli-sim/query.mjs | 6 +++++- .../providers/claude-cli-sim.test.ts | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/test/fixtures/claude-cli-sim/program.mjs b/test/fixtures/claude-cli-sim/program.mjs index 18786745b..367d14866 100644 --- a/test/fixtures/claude-cli-sim/program.mjs +++ b/test/fixtures/claude-cli-sim/program.mjs @@ -14,7 +14,10 @@ // there. A matching prompt rule replaces the prompt-marker behavior for that // turn; its emissions run in order as the model's side of the turn: // -// text / activity { text?, chunks?, delayMs? } one streamed assistant text block +// text / activity { text?, chunks?, delayMs?, echoToolResult? } +// one streamed assistant text block; with +// echoToolResult it repeats what the turn's last +// tool call returned, after `text` // tool { name, input, id?, seconds?, output?, isError?, inputStreamMs? } // a tool call and its result; with inputStreamMs // the model streams the call's input for that long diff --git a/test/fixtures/claude-cli-sim/query.mjs b/test/fixtures/claude-cli-sim/query.mjs index 74be93e76..a003ec92d 100644 --- a/test/fixtures/claude-cli-sim/query.mjs +++ b/test/fixtures/claude-cli-sim/query.mjs @@ -743,7 +743,10 @@ class SimulatedCli { /** One streamed text block. Returns false when the turn was aborted (and has been ended). */ async streamText(turn, data) { - const pieces = data.chunks ? Array.from({ length: data.chunks }, (_, i) => `chunk${i} `) : [data.text ?? 'ok'] + // A model that repeats what its last tool call returned (`echoToolResult`, + // after an optional `text` prefix). + const reply = data.echoToolResult ? `${data.text ?? ''}${turn.lastToolResultText ?? ''}` : (data.text ?? 'ok') + const pieces = data.chunks ? Array.from({ length: data.chunks }, (_, i) => `chunk${i} `) : [reply] const pace = data.chunks ? (data.delayMs ?? SLOW_DELAY_MS) : 0 this.emitStream(this.messageStart()) this.emitStream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) @@ -779,6 +782,7 @@ class SimulatedCli { toolResult(toolUseId, content, { isError = false, toolUseResult, extra = {} } = {}) { const uuid = randomUUID() const block = { tool_use_id: toolUseId, type: 'tool_result', content, ...(isError ? { is_error: true } : { is_error: false }) } + if (this.turn) this.turn.lastToolResultText = Array.isArray(content) ? content.map((part) => part?.text ?? '').join('') : String(content ?? '') this.emit({ type: 'user', message: { role: 'user', content: [block] }, parent_tool_use_id: null, uuid, timestamp: new Date().toISOString(), tool_use_result: toolUseResult, ...extra }) this.transcript.append({ type: 'user', uuid, message: { role: 'user', content: [block] }, toolUseResult }) // Sends absorbed while the tool ran join the turn as queued_command diff --git a/test/integration/providers/claude-cli-sim.test.ts b/test/integration/providers/claude-cli-sim.test.ts index d89efe330..7e46af9f2 100644 --- a/test/integration/providers/claude-cli-sim.test.ts +++ b/test/integration/providers/claude-cli-sim.test.ts @@ -188,6 +188,23 @@ describe('Claude CLI simulator programs', () => { expect(results(s.messages)).toEqual([]) }) + it('an MCP tool step calls the session\'s stdio server, and a text step can repeat what it returned', async () => { + const probe = path.join(REPO_ROOT, 'test/integration/providers/claude-freshell-mcp-probe-server.mjs') + const program = { rules: [{ on: 'msg:prompt', match: { text: 'what did the server see' }, emit: [ + { kind: 'tool', data: { name: 'mcp__freshell__context', input: {} } }, + { kind: 'text', data: { text: 'The server saw: ', echoToolResult: true } }, + ] }] } + const s = await open( + { mcpServers: { freshell: { type: 'stdio', command: process.execPath, args: [probe] } } }, + { FRESHELL_CLAUDE_SIM_PROGRAM: JSON.stringify(program), FRESHELL: '1', FRESHELL_TAB_ID: 'tab-p', FRESHELL_PANE_ID: 'pane-p' }, + ) + s.prompt.push('what did the server see') + await s.until(() => results(s.messages).length === 1) + const reply = s.messages.filter((m) => m.type === 'assistant').at(-1).message.content[0].text as string + expect(reply.startsWith('The server saw: ')).toBe(true) + expect(JSON.parse(reply.slice('The server saw: '.length))).toMatchObject({ FRESHELL: '1', FRESHELL_TAB_ID: 'tab-p', FRESHELL_PANE_ID: 'pane-p' }) + }) + it('a completion rule ends the turn with the scripted subtype; a `once` rule then falls back to the prompt markers', async () => { const s = await open({}, program([{ on: 'msg:prompt', once: true, emit: [{ kind: 'text', data: { text: 'partial' } }, { kind: 'completion', data: { subtype: 'error_max_turns', text: 'too many turns' } }] }])) s.prompt.push('first') From 5a91a159214b61c89d5b14d952a96a92a66599ff Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:24:30 -0700 Subject: [PATCH 11/21] test(e2e): a freshclaude/kilroy agent uses the Freshell MCP; the Settings toggle removes it Through the real Rust server, the production sidecar and the CLI simulator: the agent's list-tabs call through the Freshell MCP server the server resolved lists the open tabs (its pane context and recipe recorded without the token), for freshclaude and kilroy; with Settings > Advanced > Agent MCP server turned off, a new pane's agent has no Freshell MCP and the same call is "No such tool available". Co-Authored-By: Claude Opus 5.5 --- .../freshclaude-freshell-mcp-rust.spec.ts | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 test/e2e-browser/specs/freshclaude-freshell-mcp-rust.spec.ts diff --git a/test/e2e-browser/specs/freshclaude-freshell-mcp-rust.spec.ts b/test/e2e-browser/specs/freshclaude-freshell-mcp-rust.spec.ts new file mode 100644 index 000000000..462890001 --- /dev/null +++ b/test/e2e-browser/specs/freshclaude-freshell-mcp-rust.spec.ts @@ -0,0 +1,132 @@ +// FRESHCLAUDE FRESHELL MCP -- api-hosted-ops plan §2.4 / §5.5 (audit G19): +// a freshclaude or kilroy pane's agent gets the ordinary Freshell MCP server, +// the way a terminal pane's CLI does, end to end through the real Rust +// server, the PRODUCTION Claude sidecar and the Claude CLI simulator +// (helpers/claude-sim-lane.ts). The simulator starts the stdio MCP command +// the Rust server resolved (dist/tools/freshell-mcp/server.js, built here by +// ensureMcpServerBuilt) with the environment the CLI got, the way the pinned +// CLI does (conformance scenario `mcp`), and calls its tools for the model. +// +// 1. the agent calls the Freshell MCP `list-tabs` and its reply lists the +// open tabs: the command Rust resolved, the endpoint and the token in +// the sidecar's environment all work against the real server; +// 2. with Settings → Advanced → "Agent MCP server" turned off, a new pane's +// agent has no Freshell MCP server: the same call is "No such tool". +// +// CLOUD-LEGALITY: no CLI binary and no network (the simulator stands in for +// the CLI; the MCP server talks only to this test's own loopback server); +// every wait polls observed state. This spec must NOT be added to +// CLOUD_SKIP_SPECS. + +import { test, expect } from '../helpers/fixtures.js' +import { ClaudeSimLane, onPrompt, type ClaudeSimLaneOptions, type ClaudeSimPane } from '../helpers/claude-sim-lane.js' +import { ensureMcpServerBuilt, REPO_ROOT } from '../helpers/mcp-stdio-client.js' + +const PROMPT = 'which tabs are open?' +const REPLY_PREFIX = 'Open tabs: ' +/** A tab the agent's list-tabs must find, named so its title is unmistakable. */ +const PROBE_TAB = 'g19 probe tab' + +/** The model calls the Freshell MCP tool's list-tabs, then repeats its answer. */ +const program = { + rules: [onPrompt(PROMPT, [ + { kind: 'tool', data: { name: 'mcp__freshell__freshell', input: { action: 'list-tabs' } } }, + { kind: 'text', data: { text: REPLY_PREFIX, echoToolResult: true } }, + ])], +} + +async function withLane( + browser: Parameters[0], + options: ClaudeSimLaneOptions, + body: (lane: ClaudeSimLane) => Promise, +) { + const lane = await ClaudeSimLane.start(browser, options) + try { + await body(lane) + } finally { + await lane.attachLogsOnFailure(test.info()) + await lane.stop() + } +} + +async function api(lane: ClaudeSimLane, method: 'GET' | 'POST', route: string, body?: unknown): Promise { + const response = await fetch(`${lane.info.baseUrl}${route}`, { + method, + headers: { 'x-auth-token': lane.info.token, ...(body ? { 'content-type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) + expect(response.ok, `${method} ${route} answered ${response.status}`).toBe(true) + return response.json() +} + +/** The agent's reply to PROMPT in `pane`, once its turn is over. */ +async function reply(lane: ClaudeSimLane, pane: ClaudeSimPane) { + await lane.send(pane, PROMPT) + await lane.waitForIdle(pane) + const turn = pane.paneRoot.getByRole('article', { name: /transcript turn$/ }).filter({ hasText: REPLY_PREFIX }) + await expect(turn).toHaveCount(1, { timeout: 30_000 }) + return turn +} + +/** The `query()` options the simulated CLI recorded for its sessions, in order. */ +function queryOptions(lane: ClaudeSimLane): Array> { + return lane.ledger().filter((row) => row.kind === 'query').map((row) => row.options as Record) +} + +test.describe('freshclaude Freshell MCP (production sidecar over the CLI simulator, Rust server)', () => { + test.setTimeout(180_000) + + test.beforeAll(() => { + // The server resolves the built MCP server (NODE_ENV=production). + ensureMcpServerBuilt(REPO_ROOT) + }) + + for (const flavour of ['freshclaude', 'kilroy'] as const) { + test(`${flavour}: the agent lists the open tabs through the Freshell MCP server`, async ({ browser }) => { + await withLane(browser, { flavour, program }, async (lane) => { + const pane = await lane.openPane() + await api(lane, 'POST', '/api/tabs', { name: PROBE_TAB, mode: 'shell' }) + + const turn = await reply(lane, pane) + // The real server's own tabs, read through the MCP server with the + // endpoint and token the server gave the sidecar. + await expect(turn).toContainText(PROBE_TAB) + await expect(turn).toContainText(pane.tabId) + await expect(turn).not.toContainText('No such tool') + + // The CLI got the recipe the server resolved, never with the token in it. + const [options] = queryOptions(lane) + expect(options.mcpServers?.freshell?.args?.some((arg: string) => arg.endsWith('freshell-mcp/server.js'))).toBe(true) + expect(JSON.stringify(options.mcpServers)).not.toContain(lane.info.token) + expect(options.envContext).toMatchObject({ + FRESHELL: '1', + FRESHELL_TAB_ID: pane.tabId, + FRESHELL_PANE_ID: pane.paneId, + }) + }) + }) + } + + test('freshclaude: with "Agent MCP server" turned off in Settings, a new pane\'s agent has no Freshell MCP', async ({ browser }) => { + await withLane(browser, { flavour: 'freshclaude', program }, async (lane) => { + const { page } = lane + await page.getByRole('button', { name: /^Settings/ }).click() + await page.getByRole('tab', { name: /^Advanced$/i }).click() + const toggle = page.getByRole('switch', { name: 'Agent MCP server' }) + await expect(toggle).toBeChecked() + await toggle.click() + await expect(toggle).not.toBeChecked() + await expect.poll(async () => (await api(lane, 'GET', '/api/settings')).codingCli?.mcpServer, { + timeout: 15_000, + message: 'the server saved the setting', + }).toBe(false) + await page.getByRole('button', { name: /^Coding Agents/ }).click() + + const pane = await lane.openPane() + const turn = await reply(lane, pane) + await expect(turn).toContainText('No such tool available: mcp__freshell__freshell') + const [options] = queryOptions(lane) + expect(options.mcpServers).toBeUndefined() + }) + }) +}) From 1f1d7fadceb14c2ca64b50e1330ec0c0c90e7905 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:24:45 -0700 Subject: [PATCH 12/21] docs(readme): Freshclaude and Kilroy agents get the Freshell MCP server Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 9a185ec05..23205c92a 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,13 @@ are separate resources. Claude fresh-agent panes use the isolated `crates/freshell-claude-sidecar` package, which wraps the Claude SDK over newline-delimited JSON on stdin/stdout. The sidecar is not a network service. +Freshclaude and Kilroy panes give their agent the same Freshell MCP server, so +the agent can list and drive tabs and panes; its default target is the pane it +runs in. The **Agent MCP server** switch (Settings → Advanced) controls this: +turning it off removes the server from Freshclaude and Kilroy agents started or +resumed after the change, with no restart. Terminal panes do not follow the +switch yet. + ### Rust server scope The Rust server supports the browser UI, terminal and session workflows, the From a11b23b473ada20927f175fa55e2d4cd6951e6d6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:28:14 -0700 Subject: [PATCH 13/21] test(freshclaude): drop a no-op struct update in the MCP launch rig Co-Authored-By: Claude Opus 5.5 --- crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs b/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs index 17bb47735..5846d3cd1 100644 --- a/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs +++ b/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs @@ -55,7 +55,6 @@ async fn rig_with( hosted_claude: hosted, env: vec![("KILROY_ENABLED".into(), "1".into())], freshell_mcp: Some(Arc::clone(&cell)), - ..RigOptions::default() }, ) .await; From f6337aabbe9481197cbdbbdc0cd7a1458f88f689 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:30:46 -0700 Subject: [PATCH 14/21] test(freshclaude): MCP launch tests wait for each turn on disk and for the owner record Under parallel load a fork could read the transcript before the CLI flushed the turn ("nothing to fork yet"), and the owner record can trail the first result. The tests now wait for both, the way the fork suite does; 48 parallel runs stay green. Co-Authored-By: Claude Opus 5.5 --- .../tests/freshagent_claude_mcp_launch.rs | 88 +++++++++++++------ 1 file changed, 61 insertions(+), 27 deletions(-) diff --git a/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs b/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs index 5846d3cd1..a127e56ec 100644 --- a/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs +++ b/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs @@ -134,9 +134,15 @@ async fn create_for_pane( .to_string() } -/// One finished turn per prompt, so the conversation is on disk; returns the -/// durable id the first turn reported. -async fn converse(conn: &mut RigConnection, session_id: &str, prompts: &[&str]) -> String { +/// One finished turn per prompt, each on disk before the next (the CLI flushes +/// a transcript line 95-200 ms after its SDK frame, and the resumes, undo and +/// fork read the transcript); returns the durable id the first turn reported. +async fn converse( + rig: &ClaudeRig, + conn: &mut RigConnection, + session_id: &str, + prompts: &[&str], +) -> String { for (n, prompt) in prompts.iter().enumerate() { let results_before = conn.events_seen("freshAgent.result"); conn.send_text(session_id, &format!("send-{n}"), prompt) @@ -149,17 +155,42 @@ async fn converse(conn: &mut RigConnection, session_id: &str, prompts: &[&str]) ); conn.read_for(std::time::Duration::from_millis(50)).await; } + let path = rig + .wait_for_transcript(&conn.init_session_id(session_id)) + .await; + loop { + let lines: Vec = std::fs::read_to_string(&path) + .unwrap_or_default() + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + let at = lines.iter().rposition(|line| { + line["type"] == "user" && line["message"]["content"][0]["text"] == *prompt + }); + if at.is_some_and(|at| lines[at..].iter().any(|line| line["type"] == "assistant")) { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "the turn for {prompt:?} never reached disk" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } } - conn.frames - .iter() - .find_map(|v| { - (v["type"] == "freshAgent.event" - && v["event"]["type"] == "freshAgent.session.init" - && v["event"]["sessionId"] == session_id) - .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) - .flatten() - }) - .expect("the first turn reported the durable id") + conn.init_session_id(session_id) +} + +/// The owner pair (epoch, generation) of the session's runtime owner record, +/// once the server has broadcast it (it can trail the first turn's result). +async fn owner_pair(conn: &mut RigConnection, durable: &str) -> (u64, u64) { + conn.wait_for("the session's owner record", |v| { + v["type"] == "session.runtimeOwner" + && v["provider"] == "claude" + && v["sessionId"] == durable + }) + .await; + conn.owner_pair(durable) + .expect("the session's owner record names its pair") } /// The session's sidecar dies on its own: the session is untracked, the state a @@ -271,7 +302,7 @@ async fn an_attach_resume_hands_the_cli_the_freshell_mcp_for_the_attaching_pane( let mut conn = rig.connect().await; let session_id = create_for_pane(&mut conn, "create-1", "freshclaude", ("tab-1", "pane-1")).await; - let durable = converse(&mut conn, &session_id, &["first prompt"]).await; + let durable = converse(&rig, &mut conn, &session_id, &["first prompt"]).await; kill_the_sidecar(&rig, &mut conn).await; // The pane, moved to another tab meanwhile, attaches again (a reload). @@ -295,7 +326,7 @@ async fn a_handoff_resume_hands_the_cli_the_freshell_mcp() { let mut conn = rig.connect().await; let session_id = create_for_pane(&mut conn, "create-1", "freshclaude", ("tab-1", "pane-1")).await; - let durable = converse(&mut conn, &session_id, &["first prompt"]).await; + let durable = converse(&rig, &mut conn, &session_id, &["first prompt"]).await; kill_the_sidecar(&rig, &mut conn).await; let (status, answer) = rig @@ -323,11 +354,15 @@ async fn an_undo_respawn_runs_for_the_same_pane() { let mut conn = rig.connect().await; let session_id = create_for_pane(&mut conn, "create-1", "freshclaude", ("tab-1", "pane-1")).await; - let durable = converse(&mut conn, &session_id, &["first prompt", "second prompt"]).await; + let durable = converse( + &rig, + &mut conn, + &session_id, + &["first prompt", "second prompt"], + ) + .await; - let (epoch, generation) = conn - .owner_pair(&durable) - .expect("the session's owner record was broadcast"); + let (epoch, generation) = owner_pair(&mut conn, &durable).await; conn.send(json!({ "type": "freshAgent.undo", "provider": "claude", @@ -358,21 +393,20 @@ async fn a_fork_child_runs_for_the_forking_pane() { let mut conn = rig.connect().await; let session_id = create_for_pane(&mut conn, "create-1", "freshclaude", ("tab-1", "pane-1")).await; - let durable = converse(&mut conn, &session_id, &["first prompt"]).await; + let durable = converse(&rig, &mut conn, &session_id, &["first prompt"]).await; - let mut fork = json!({ + let (epoch, generation) = owner_pair(&mut conn, &durable).await; + conn.send(json!({ "type": "freshAgent.fork", "provider": "claude", "sessionType": "freshclaude", "sessionId": session_id, "requestId": "fork-1", "tabId": "tab-1", - }); - if let Some((epoch, generation)) = conn.owner_pair(&durable) { - fork["observedEpoch"] = json!(epoch); - fork["observedGeneration"] = json!(generation); - } - conn.send(fork).await; + "observedEpoch": epoch, + "observedGeneration": generation, + })) + .await; let forked = conn .wait_for("the fork's answer", |v| { (v["type"] == "freshAgent.forked" && v["requestId"] == "fork-1") From 9e980c9211b087e8611a3d3cfef94fda539a04b3 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:32:21 -0700 Subject: [PATCH 15/21] test(claude-sidecar): the Freshell MCP contract polls the transcript for the tool result The CLI flushes the tool_result line about 100 ms after the turn's frames, so reading the transcript right after the result could miss it under load (1 of 16 parallel runs). 18 parallel runs now pass. Co-Authored-By: Claude Opus 5.5 --- .../providers/claude-freshell-mcp.mjs | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/test/integration/providers/claude-freshell-mcp.mjs b/test/integration/providers/claude-freshell-mcp.mjs index d0736d117..2ed16a743 100644 --- a/test/integration/providers/claude-freshell-mcp.mjs +++ b/test/integration/providers/claude-freshell-mcp.mjs @@ -121,20 +121,30 @@ try { receipt.toolNames = (init.tools ?? []).map(tool => (typeof tool === 'string' ? tool : tool?.name)).filter(name => String(name).startsWith('mcp__')) receipt.probeToolListed = receipt.toolNames.includes(PROBE_TOOL) await waitFor(frame => frame.type === 'sdk.result', 45_000, 'the turn result') - // The CLI's tool result for the probe call (the user message carrying the tool_result - // block reaches the sidecar as a transcript announcement only, so read the transcript). - const transcripts = await fixture.transcripts() - for (const relative of transcripts) { - const text = await fs.readFile(path.join(fixture.home, '.claude', relative), 'utf8') - for (const line of text.split('\n').filter(Boolean)) { - const row = JSON.parse(line) - const blocks = Array.isArray(row.message?.content) ? row.message.content : [] - for (const block of blocks) { - if (block?.type !== 'tool_result') continue - const body = Array.isArray(block.content) ? block.content.map(part => part?.text ?? '').join('') : String(block.content ?? '') - try { receipt.toolResultReport = JSON.parse(body) } catch { receipt.toolResultReport = { unparsed: body.slice(0, 200) } } + // The CLI's tool result for the probe call: the user message carrying the + // tool_result block reaches the sidecar as a transcript announcement only, + // and the CLI flushes the line about 100 ms after the frame, so poll the + // transcript for it. + failureStage = 'tool result' + const toolResultReport = async () => { + for (const relative of await fixture.transcripts()) { + const text = await fs.readFile(path.join(fixture.home, '.claude', relative), 'utf8') + for (const line of text.split('\n').filter(Boolean)) { + let row + try { row = JSON.parse(line) } catch { continue } + const blocks = Array.isArray(row.message?.content) ? row.message.content : [] + for (const block of blocks) { + if (block?.type !== 'tool_result') continue + const body = Array.isArray(block.content) ? block.content.map(part => part?.text ?? '').join('') : String(block.content ?? '') + try { return JSON.parse(body) } catch { return { unparsed: body.slice(0, 200) } } + } } } + return null + } + for (let waited = 0; waited < 10_000 && receipt.toolResultReport === null; waited += 50) { + receipt.toolResultReport = await toolResultReport() + if (receipt.toolResultReport === null) await sleep(50) } failureStage = 'probe report' From 679bcf8ce92bfc3273948ff7bf72ee3c0616fd6e Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:36:53 -0700 Subject: [PATCH 16/21] refactor(freshclaude): resolve the Freshell MCP recipe before spawning the sidecar No sidecar process waits on the settings read and command resolution. Pinned by the MCP launch and launch parity integration tests. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude/establish.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index 879bf14f4..bbc5ccc4a 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -392,6 +392,9 @@ impl FreshClaudeState { lease_guard: Option<&mut crate::FreshSessionLeaseGuard>, start: Option>, ) -> Result { + // The Freshell MCP recipe is resolved per launch (G19), so the + // setting applies to the next sidecar without a restart. + let spec = &self.freshell_mcp.launch(spec.clone()).await; let SpawnedSidecar { mut child, mut stdin, @@ -403,9 +406,6 @@ impl FreshClaudeState { .spawn_sidecar_process(&spec.session_type) .await .map_err(EstablishError::Spawn)?; - // The Freshell MCP recipe is resolved per launch (G19), so the - // setting applies to the next sidecar without a restart. - let spec = &self.freshell_mcp.launch(spec.clone()).await; // Arm the lease's TTL tree-kill path now that the child and its tag exist. if let Some(guard) = lease_guard { if let Some(pid) = child.id() { From 01e4c571c7805852071dd408f0263aef2a29afa1 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:37:23 -0700 Subject: [PATCH 17/21] test(e2e): the Freshell MCP spec checks the server's tab list, not the browser tab id The agent API's tab list holds the tabs the server knows (here the tab the test created through REST); the browser's boot tab id is not part of it. Co-Authored-By: Claude Opus 5.5 --- .../e2e-browser/specs/freshclaude-freshell-mcp-rust.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/e2e-browser/specs/freshclaude-freshell-mcp-rust.spec.ts b/test/e2e-browser/specs/freshclaude-freshell-mcp-rust.spec.ts index 462890001..046be40e8 100644 --- a/test/e2e-browser/specs/freshclaude-freshell-mcp-rust.spec.ts +++ b/test/e2e-browser/specs/freshclaude-freshell-mcp-rust.spec.ts @@ -88,10 +88,10 @@ test.describe('freshclaude Freshell MCP (production sidecar over the CLI simulat await api(lane, 'POST', '/api/tabs', { name: PROBE_TAB, mode: 'shell' }) const turn = await reply(lane, pane) - // The real server's own tabs, read through the MCP server with the - // endpoint and token the server gave the sidecar. + // The real server's own tab list (GET /api/tabs), read through the MCP + // server with the endpoint and token the server gave the sidecar. + await expect(turn).toContainText('"status": "ok"') await expect(turn).toContainText(PROBE_TAB) - await expect(turn).toContainText(pane.tabId) await expect(turn).not.toContainText('No such tool') // The CLI got the recipe the server resolved, never with the token in it. From 6280c0f51c21590880b995867928a7cac66fa7ef Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:03:45 -0700 Subject: [PATCH 18/21] fix(freshclaude): a handoff resume runs the CLI for the pane that adopts the session Switching a terminal Claude pane to freshclaude/kilroy, or stop-and-reopen, posts the handoff with the pane's tabId/paneId. The claude lane dropped them, so the resumed CLI and its Freshell MCP had no FRESHELL_TAB_ID/PANE_ID and an untargeted MCP call fell back to the user's active pane. The handoff now threads the ids into the resume's launch spec, for both flavors. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 10 ++++-- .../src/session_handoff.rs | 4 +++ .../tests/freshagent_claude_mcp_launch.rs | 31 ++++++++++++++----- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index b07d51e2c..fcfa1a59b 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -1427,11 +1427,17 @@ impl FreshClaudeState { /// vs kilroy, so a kilroy session resumes AS kilroy (the created/snapshot /// frames keep the flavor; never map by provider alone). Registers under /// the durable id itself and returns the constructed owner identity. + /// `tab_id`/`pane_id` name the pane that adopts the session (the client + /// names it on every handoff); the resumed CLI runs for that pane, so its + /// Freshell MCP defaults to it. + #[allow(clippy::too_many_arguments)] pub(crate) async fn resume_for_handoff( &self, session_type: &str, session_id: &str, cwd: Option<&str>, + tab_id: Option<&str>, + pane_id: Option<&str>, operation_id: &str, generation: u64, ) -> Result { @@ -1481,8 +1487,8 @@ impl FreshClaudeState { } }; let msg = FreshAgentAttach { - tab_id: None, - pane_id: None, + tab_id: tab_id.map(str::to_string), + pane_id: pane_id.map(str::to_string), provider: freshell_protocol::AgentProvider::Claude, session_id: session_id.to_string(), session_type: flavor, diff --git a/crates/freshell-freshagent/src/session_handoff.rs b/crates/freshell-freshagent/src/session_handoff.rs index 7f556c5d1..0d760a8a9 100644 --- a/crates/freshell-freshagent/src/session_handoff.rs +++ b/crates/freshell-freshagent/src/session_handoff.rs @@ -4110,6 +4110,8 @@ impl SessionHandoffRunner { "freshclaude", &req.session_id, req.cwd.as_deref(), + req.tab_id.as_deref(), + req.pane_id.as_deref(), operation_id, generation, ) @@ -4121,6 +4123,8 @@ impl SessionHandoffRunner { "kilroy", &req.session_id, req.cwd.as_deref(), + req.tab_id.as_deref(), + req.pane_id.as_deref(), operation_id, generation, ) diff --git a/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs b/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs index a127e56ec..9d19a2fba 100644 --- a/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs +++ b/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs @@ -320,12 +320,14 @@ async fn an_attach_resume_hands_the_cli_the_freshell_mcp_for_the_attaching_pane( assert_mcp_for_pane(&rig, &resumed, Some(("tab-9", "pane-1")), "attach-resume"); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn a_handoff_resume_hands_the_cli_the_freshell_mcp() { +/// A terminal-to-agent switch or a stop-and-reopen posts the handoff for the +/// pane that adopts the session (the client always names it); the resumed CLI +/// runs for that pane. +async fn a_handoff_resume_runs_for_the_adopting_pane(session_type: &str) { let (rig, _) = rig_with(resolved(), false).await; let mut conn = rig.connect().await; let session_id = - create_for_pane(&mut conn, "create-1", "freshclaude", ("tab-1", "pane-1")).await; + create_for_pane(&mut conn, "create-1", session_type, ("tab-1", "pane-1")).await; let durable = converse(&rig, &mut conn, &session_id, &["first prompt"]).await; kill_the_sidecar(&rig, &mut conn).await; @@ -336,16 +338,31 @@ async fn a_handoff_resume_hands_the_cli_the_freshell_mcp() { "provider": "claude", "sessionId": durable, "targetKind": "fresh-agent", - "sessionType": "freshclaude", + "sessionType": session_type, "cwd": rig.workspace.to_str().expect("utf-8 path"), + "tabId": "tab-7", + "paneId": "pane-7", }), ) .await; assert_eq!(status, 200, "the handoff resumes the session: {answer}"); - // A handoff names no pane: the pane that adopts the session is not known - // yet, so the CLI reaches the server without a pane of its own. let resumed = wait_for_query(&rig, "the handoff resume", |o| o["resume"] == durable).await; - assert_mcp_for_pane(&rig, &resumed, None, "handoff resume"); + assert_mcp_for_pane( + &rig, + &resumed, + Some(("tab-7", "pane-7")), + &format!("{session_type} handoff resume"), + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_freshclaude_handoff_resume_runs_for_the_adopting_pane() { + a_handoff_resume_runs_for_the_adopting_pane("freshclaude").await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_kilroy_handoff_resume_runs_for_the_adopting_pane() { + a_handoff_resume_runs_for_the_adopting_pane("kilroy").await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From cf72cdd0fc195ff9e1753cadb0aa0aeacd31d1c2 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:07:00 -0700 Subject: [PATCH 19/21] fix(claude-sidecar): a malformed Freshell MCP recipe is logged, not dropped silently The server logs each launch's recipe as resolved before the sidecar checks it, so a recipe the sidecar could not use (a non-string argument, an empty command) left the pane without the Freshell MCP and no log said so. The sidecar now logs a warn record, mcp.freshell_recipe_rejected, with the session id, and still starts the session without it. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-claude-sidecar/index.mjs | 2 +- .../freshell-claude-sidecar/mcp-servers.mjs | 19 ++++++++-- test/unit/claude-sidecar/mcp-servers.test.ts | 38 +++++++++++++++++-- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index 2bd12b899..92cac7cfe 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -563,7 +563,7 @@ function handleCreate(req) { plugins: Array.isArray(req.plugins) ? req.plugins.map(path => ({ type: 'local', path })) : undefined, - mcpServers: mcpServersFor(req), + mcpServers: mcpServersFor(req, process.env, { fields: { sessionId } }), pathToClaudeCodeExecutable: process.env.CLAUDE_CMD || undefined, includePartialMessages: true, // This is the SDK's native persisted-user witness. Without the replay diff --git a/crates/freshell-claude-sidecar/mcp-servers.mjs b/crates/freshell-claude-sidecar/mcp-servers.mjs index da48b6b98..1193e25bd 100644 --- a/crates/freshell-claude-sidecar/mcp-servers.mjs +++ b/crates/freshell-claude-sidecar/mcp-servers.mjs @@ -16,6 +16,7 @@ // CLI passes its environment on to the stdio MCP child. import { usesHostedClaudeRuntime } from './hosted.mjs' +import { log as sidecarLog } from './log.mjs' /** The MCP server name both recipes register (its tools are `mcp__freshell__*`). */ export const FRESHELL_MCP_SERVER_NAME = 'freshell' @@ -36,15 +37,25 @@ function ordinaryRecipe(recipe) { /** * The `mcpServers` option for the session `req` creates: the hosted recipe, - * the ordinary recipe, or undefined (never both, never an `env` key). + * the ordinary recipe, or undefined (never both, never an `env` key). An + * ordinary recipe that is present but malformed is left out and logged as a + * warning (`mcp.freshell_recipe_rejected`, with `fields`): the server already + * logged the launch as resolved, so this record is the only sign the pane + * runs without the Freshell MCP. */ -export function mcpServersFor(req, env = process.env) { +export function mcpServersFor(req, env = process.env, { log = sidecarLog, fields = {} } = {}) { if (usesHostedClaudeRuntime(env)) { if (req?.hostedMcp !== true) return undefined return { [FRESHELL_MCP_SERVER_NAME]: { command: HOSTED_RECIPE.command, args: [...HOSTED_RECIPE.args] } } } - const recipe = ordinaryRecipe(req?.freshellMcp) - return recipe ? { [FRESHELL_MCP_SERVER_NAME]: recipe } : undefined + const sent = req?.freshellMcp + if (sent === undefined || sent === null) return undefined + const recipe = ordinaryRecipe(sent) + if (!recipe) { + log('warn', 'mcp.freshell_recipe_rejected', { ...fields, recipeType: Array.isArray(sent) ? 'array' : typeof sent }) + return undefined + } + return { [FRESHELL_MCP_SERVER_NAME]: recipe } } /** diff --git a/test/unit/claude-sidecar/mcp-servers.test.ts b/test/unit/claude-sidecar/mcp-servers.test.ts index 24272ff3b..927341763 100644 --- a/test/unit/claude-sidecar/mcp-servers.test.ts +++ b/test/unit/claude-sidecar/mcp-servers.test.ts @@ -16,12 +16,19 @@ import { describe, expect, it } from 'vitest' // @ts-expect-error JavaScript sidecar module has no declaration file. import { mcpServersFor, withFreshellContext } from '../../../crates/freshell-claude-sidecar/mcp-servers.mjs' -import { startRealSidecar } from './harness' +import { startRealSidecar, type RealSidecar } from './harness' const HOSTED_RECIPE = { freshell: { command: 'node', args: ['/opt/freshell-mcp/server.js'] } } const ORDINARY = { command: '/usr/bin/node', args: ['--import', '/repo/tsx/loader.mjs', '/repo/tools/freshell-mcp/server.ts'] } const hosted = (provider = 'claude') => ({ FRESHELL_HOSTED_FRESH_AGENT: provider }) +/** The sidecar's structured log records so far (stderr, one JSON object per line). */ +function logRecords(sidecar: RealSidecar): Array> { + return sidecar.stderr().split('\n').flatMap((line) => { + try { return [JSON.parse(line)] } catch { return [] } + }) +} + /** No server config in `servers` has an `env` key. */ function expectNoEnvKey(servers: Record> | undefined) { for (const config of Object.values(servers ?? {})) expect(Object.keys(config)).not.toContain('env') @@ -61,7 +68,7 @@ describe('mcpServersFor', () => { expectNoEnvKey(mcpServersFor({ hostedMcp: true }, hosted())) }) - it('ignores a malformed ordinary recipe instead of handing the CLI a broken server', () => { + it('leaves out a malformed ordinary recipe instead of handing the CLI a broken server, and says so', () => { for (const freshellMcp of [ { args: [] }, { command: '', args: [] }, @@ -69,9 +76,23 @@ describe('mcpServersFor', () => { { command: 'node', args: ['server.js', 7] }, 'node server.js', ]) { - expect(mcpServersFor({ freshellMcp }, {}), JSON.stringify(freshellMcp)).toBeUndefined() + const records: unknown[][] = [] + const log = (...record: unknown[]) => { records.push(record) } + expect(mcpServersFor({ freshellMcp }, {}, { log, fields: { sessionId: 's-1' } }), JSON.stringify(freshellMcp)).toBeUndefined() + expect(records, JSON.stringify(freshellMcp)).toEqual([ + ['warn', 'mcp.freshell_recipe_rejected', expect.objectContaining({ sessionId: 's-1' })], + ]) } }) + + it('logs nothing when the server sent no recipe or a well-formed one', () => { + const records: unknown[][] = [] + const log = (...record: unknown[]) => { records.push(record) } + mcpServersFor({}, {}, { log }) + mcpServersFor({ freshellMcp: null }, {}, { log }) + mcpServersFor({ freshellMcp: ORDINARY }, {}, { log }) + expect(records).toEqual([]) + }) }) describe('withFreshellContext', () => { @@ -115,6 +136,17 @@ describe('the sidecar hands query() the MCP recipe and the pane context', () => await sidecar.create({ freshellMcp: null, freshellContext: null }) const { options } = await sidecar.waitForLedger((row) => row.kind === 'query', 'the query() options') expect(options.mcpServers).toBeUndefined() + expect(logRecords(sidecar).filter((record) => record.event === 'mcp.freshell_recipe_rejected')).toEqual([]) + }) + + it('starts the session without a malformed recipe and logs a warning naming the session', async () => { + const sidecar = await startRealSidecar() + const sessionId = await sidecar.create({ freshellMcp: { command: 'node', args: ['server.js', 7] } }) + const { options } = await sidecar.waitForLedger((row) => row.kind === 'query', 'the query() options') + expect(options.mcpServers).toBeUndefined() + expect(logRecords(sidecar).filter((record) => record.event === 'mcp.freshell_recipe_rejected')).toEqual([ + expect.objectContaining({ severity: 'warn', component: 'claude-sidecar', sessionId }), + ]) }) it('passes the fixed hosted recipe, never the ordinary one, in a hosted kilroy sidecar', async () => { From ea15f63ae2df133c027ab5bd294715ff1f9941e1 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:08:59 -0700 Subject: [PATCH 20/21] test(freshclaude): the launch tests share the rig's query, turn and sidecar-kill helpers The MCP launch test and the G15 launch-parity test each carried copies of query_options, wait_for_query, kill_the_sidecar and converse. They now live on ClaudeRig. The shared converse waits for each turn to reach the transcript before the next, so the parity test's resumes and undo no longer race the CLI's transcript flush either. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-ws/tests/common/claude_rig.rs | 88 +++++++++++ .../tests/freshagent_claude_launch_parity.rs | 117 +++------------ .../tests/freshagent_claude_mcp_launch.rs | 141 ++++-------------- 3 files changed, 144 insertions(+), 202 deletions(-) diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index 033ac285b..9be80ecb7 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -539,6 +539,94 @@ impl ClaudeRig { }); RuntimeProcesses { pids, probes } } + + /// The options of the `query()` calls the simulator recorded, in order. + pub fn query_options(&self) -> Vec { + self.ledger_rows("query") + .into_iter() + .map(|row| row["options"].clone()) + .collect() + } + + /// The first recorded `query()` options matching `predicate`, once recorded. + pub async fn wait_for_query(&self, what: &str, predicate: impl Fn(&Value) -> bool) -> Value { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + if let Some(options) = self.query_options().into_iter().find(|o| predicate(o)) { + return options; + } + assert!( + tokio::time::Instant::now() < deadline, + "no query() call for {what}; recorded: {:?}", + self.query_options() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + + /// One finished turn per prompt (each `freshAgent.result`), each on disk + /// before the next: the CLI flushes a transcript line 95-200 ms after its + /// SDK frame (T3), and resumes, undo and fork read the transcript. Returns + /// the durable id the first turn reported. + pub async fn converse( + &self, + conn: &mut RigConnection, + session_id: &str, + prompts: &[&str], + ) -> String { + for (n, prompt) in prompts.iter().enumerate() { + let results_before = conn.events_seen("freshAgent.result"); + conn.send_text(session_id, &format!("send-{n}"), prompt) + .await; + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while conn.events_seen("freshAgent.result") == results_before { + assert!( + tokio::time::Instant::now() < deadline, + "turn {n} never finished" + ); + conn.read_for(Duration::from_millis(50)).await; + } + let path = self + .wait_for_transcript(&conn.init_session_id(session_id)) + .await; + loop { + let lines: Vec = std::fs::read_to_string(&path) + .unwrap_or_default() + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + let at = lines.iter().rposition(|line| { + line["type"] == "user" && line["message"]["content"][0]["text"] == *prompt + }); + if at.is_some_and(|at| lines[at..].iter().any(|line| line["type"] == "assistant")) { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "the turn for {prompt:?} never reached disk" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + conn.init_session_id(session_id) + } + + /// The first sidecar this rig booted dies on its own (SIGKILL of the Node + /// process): its session is untracked, the state a reload or a handoff + /// resumes from. Returns once `conn` has read the exit. + pub async fn kill_first_sidecar(&self, conn: &mut RigConnection) { + let pid = self.ledger_rows("boot")[0]["pid"] + .as_i64() + .expect("the boot row names the sidecar's pid") as i32; + // SAFETY: a plain signal to the sidecar this rig spawned. + assert_eq!(unsafe { libc::kill(pid, libc::SIGKILL) }, 0); + conn.wait_for("the sidecar's exit", |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.error" + && v["event"]["code"] == "SIDECAR_EXITED" + }) + .await; + } } /// A runtime's sidecar and simulated CLI, observed since they ran diff --git a/crates/freshell-ws/tests/freshagent_claude_launch_parity.rs b/crates/freshell-ws/tests/freshagent_claude_launch_parity.rs index 230b0ab1c..67bfe50cf 100644 --- a/crates/freshell-ws/tests/freshagent_claude_launch_parity.rs +++ b/crates/freshell-ws/tests/freshagent_claude_launch_parity.rs @@ -17,7 +17,7 @@ mod common; use serde_json::{json, Value}; -use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions}; async fn hosted_rig() -> (ClaudeRig, String) { let rig = ClaudeRig::start_with_options( @@ -48,80 +48,6 @@ async fn create_with_plugin(conn: &mut RigConnection, plugin: &str) -> String { .to_string() } -/// One finished turn per prompt (each `freshAgent.result`), so the conversation is -/// on disk; returns the durable id the first turn reported. -async fn converse( - rig: &ClaudeRig, - conn: &mut RigConnection, - session_id: &str, - prompts: &[&str], -) -> String { - for (n, prompt) in prompts.iter().enumerate() { - let results_before = conn.events_seen("freshAgent.result"); - conn.send_text(session_id, &format!("send-{n}"), prompt) - .await; - let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; - while conn.events_seen("freshAgent.result") == results_before { - assert!( - tokio::time::Instant::now() < deadline, - "turn {n} never finished" - ); - conn.read_for(std::time::Duration::from_millis(50)).await; - } - } - rig.wait_for_transcripts().await; - conn.frames - .iter() - .find_map(|v| { - (v["type"] == "freshAgent.event" - && v["event"]["type"] == "freshAgent.session.init" - && v["event"]["sessionId"] == session_id) - .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) - .flatten() - }) - .expect("the first turn reported the durable id") -} - -/// The session's sidecar dies on its own (SIGKILL of the Node process): the session -/// is untracked, the state a reload or a handoff resumes from. -async fn kill_the_sidecar(rig: &ClaudeRig, conn: &mut RigConnection) { - let pid = rig.ledger_rows("boot")[0]["pid"] - .as_i64() - .expect("the boot row names the sidecar's pid") as i32; - // SAFETY: a plain signal to the sidecar this rig spawned. - assert_eq!(unsafe { libc::kill(pid, libc::SIGKILL) }, 0); - conn.wait_for("the sidecar's exit", |v| { - v["type"] == "freshAgent.event" - && v["event"]["type"] == "freshAgent.error" - && v["event"]["code"] == "SIDECAR_EXITED" - }) - .await; -} - -/// The options of the `query()` calls the simulator recorded, in order. -fn query_options(rig: &ClaudeRig) -> Vec { - rig.ledger_rows("query") - .into_iter() - .map(|row| row["options"].clone()) - .collect() -} - -/// The first recorded `query()` options matching `predicate`, once recorded. -async fn wait_for_query(rig: &ClaudeRig, what: &str, predicate: impl Fn(&Value) -> bool) -> Value { - let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; - loop { - if let Some(options) = query_options(rig).into_iter().find(|o| predicate(o)) { - return options; - } - assert!( - tokio::time::Instant::now() < deadline, - "no query() call for {what}; recorded: {:?}", - query_options(rig) - ); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - } -} - fn assert_carries_plugin_and_hosted_mcp(options: &Value, plugin: &str, launch: &str) { assert_eq!( options["plugins"], @@ -140,7 +66,7 @@ async fn the_create_carries_the_panes_plugins_and_the_hosted_mcp() { let mut conn = rig.connect().await; create_with_plugin(&mut conn, &plugin).await; - let options = query_options(&rig); + let options = rig.query_options(); assert_eq!(options.len(), 1, "one sidecar booted: {options:?}"); assert_carries_plugin_and_hosted_mcp(&options[0], &plugin, "create"); } @@ -150,8 +76,10 @@ async fn an_attach_resume_carries_the_bound_plugins_and_the_hosted_mcp() { let (rig, plugin) = hosted_rig().await; let mut conn = rig.connect().await; let session_id = create_with_plugin(&mut conn, &plugin).await; - let durable = converse(&rig, &mut conn, &session_id, &["first prompt"]).await; - kill_the_sidecar(&rig, &mut conn).await; + let durable = rig + .converse(&mut conn, &session_id, &["first prompt"]) + .await; + rig.kill_first_sidecar(&mut conn).await; // The pane attaches again (a reload): the lane resumes the session. conn.send(json!({ @@ -167,7 +95,9 @@ async fn an_attach_resume_carries_the_bound_plugins_and_the_hosted_mcp() { }) .await; - let resumed = wait_for_query(&rig, "the attach-resume", |o| o["resume"] == durable).await; + let resumed = rig + .wait_for_query("the attach-resume", |o| o["resume"] == durable) + .await; assert_carries_plugin_and_hosted_mcp(&resumed, &plugin, "attach-resume"); } @@ -176,8 +106,10 @@ async fn a_handoff_resume_carries_the_bound_plugins_and_the_hosted_mcp() { let (rig, plugin) = hosted_rig().await; let mut conn = rig.connect().await; let session_id = create_with_plugin(&mut conn, &plugin).await; - let durable = converse(&rig, &mut conn, &session_id, &["first prompt"]).await; - kill_the_sidecar(&rig, &mut conn).await; + let durable = rig + .converse(&mut conn, &session_id, &["first prompt"]) + .await; + rig.kill_first_sidecar(&mut conn).await; let (status, answer) = rig .post_json( @@ -193,7 +125,9 @@ async fn a_handoff_resume_carries_the_bound_plugins_and_the_hosted_mcp() { .await; assert_eq!(status, 200, "the handoff resumes the session: {answer}"); - let resumed = wait_for_query(&rig, "the handoff resume", |o| o["resume"] == durable).await; + let resumed = rig + .wait_for_query("the handoff resume", |o| o["resume"] == durable) + .await; assert_carries_plugin_and_hosted_mcp(&resumed, &plugin, "handoff resume"); } @@ -202,13 +136,9 @@ async fn a_rollback_respawn_carries_the_bound_plugins_and_the_hosted_mcp() { let (rig, plugin) = hosted_rig().await; let mut conn = rig.connect().await; let session_id = create_with_plugin(&mut conn, &plugin).await; - let durable = converse( - &rig, - &mut conn, - &session_id, - &["first prompt", "second prompt"], - ) - .await; + let durable = rig + .converse(&mut conn, &session_id, &["first prompt", "second prompt"]) + .await; // The client sends the owner pair of the latest `session.runtimeOwner` frame // for the durable id with every rollback. @@ -239,9 +169,10 @@ async fn a_rollback_respawn_carries_the_bound_plugins_and_the_hosted_mcp() { }) .await; assert_eq!(ack["event"]["type"], "freshAgent.rolledBack", "{ack}"); - let fork = wait_for_query(&rig, "the undo's fork", |o| { - o["forkSession"] == true && o["resume"] == durable - }) - .await; + let fork = rig + .wait_for_query("the undo's fork", |o| { + o["forkSession"] == true && o["resume"] == durable + }) + .await; assert_carries_plugin_and_hosted_mcp(&fork, &plugin, "rollback respawn"); } diff --git a/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs b/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs index 9d19a2fba..7b3e98bc5 100644 --- a/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs +++ b/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs @@ -21,7 +21,7 @@ use std::sync::Arc; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; -use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions}; use common::AUTH_TOKEN; use freshell_freshagent::claude_freshell_mcp::{ClaudeFreshellMcp, ClaudeFreshellMcpResolution}; @@ -61,30 +61,6 @@ async fn rig_with( (rig, cell) } -/// The options of the `query()` calls the simulator recorded, in order. -fn query_options(rig: &ClaudeRig) -> Vec { - rig.ledger_rows("query") - .into_iter() - .map(|row| row["options"].clone()) - .collect() -} - -/// The first recorded `query()` options matching `predicate`, once recorded. -async fn wait_for_query(rig: &ClaudeRig, what: &str, predicate: impl Fn(&Value) -> bool) -> Value { - let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; - loop { - if let Some(options) = query_options(rig).into_iter().find(|o| predicate(o)) { - return options; - } - assert!( - tokio::time::Instant::now() < deadline, - "no query() call for {what}; recorded: {:?}", - query_options(rig) - ); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - } -} - /// The simulator's digest of a credential value (`query.mjs`). fn digest(value: &str) -> String { format!("{:x}", Sha256::digest(value.as_bytes()))[..12].to_string() @@ -134,52 +110,6 @@ async fn create_for_pane( .to_string() } -/// One finished turn per prompt, each on disk before the next (the CLI flushes -/// a transcript line 95-200 ms after its SDK frame, and the resumes, undo and -/// fork read the transcript); returns the durable id the first turn reported. -async fn converse( - rig: &ClaudeRig, - conn: &mut RigConnection, - session_id: &str, - prompts: &[&str], -) -> String { - for (n, prompt) in prompts.iter().enumerate() { - let results_before = conn.events_seen("freshAgent.result"); - conn.send_text(session_id, &format!("send-{n}"), prompt) - .await; - let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; - while conn.events_seen("freshAgent.result") == results_before { - assert!( - tokio::time::Instant::now() < deadline, - "turn {n} never finished" - ); - conn.read_for(std::time::Duration::from_millis(50)).await; - } - let path = rig - .wait_for_transcript(&conn.init_session_id(session_id)) - .await; - loop { - let lines: Vec = std::fs::read_to_string(&path) - .unwrap_or_default() - .lines() - .filter_map(|line| serde_json::from_str(line).ok()) - .collect(); - let at = lines.iter().rposition(|line| { - line["type"] == "user" && line["message"]["content"][0]["text"] == *prompt - }); - if at.is_some_and(|at| lines[at..].iter().any(|line| line["type"] == "assistant")) { - break; - } - assert!( - tokio::time::Instant::now() < deadline, - "the turn for {prompt:?} never reached disk" - ); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - } - } - conn.init_session_id(session_id) -} - /// The owner pair (epoch, generation) of the session's runtime owner record, /// once the server has broadcast it (it can trail the first turn's result). async fn owner_pair(conn: &mut RigConnection, durable: &str) -> (u64, u64) { @@ -193,22 +123,6 @@ async fn owner_pair(conn: &mut RigConnection, durable: &str) -> (u64, u64) { .expect("the session's owner record names its pair") } -/// The session's sidecar dies on its own: the session is untracked, the state a -/// reload or a handoff resumes from. -async fn kill_the_sidecar(rig: &ClaudeRig, conn: &mut RigConnection) { - let pid = rig.ledger_rows("boot")[0]["pid"] - .as_i64() - .expect("the boot row names the sidecar's pid") as i32; - // SAFETY: a plain signal to the sidecar this rig spawned. - assert_eq!(unsafe { libc::kill(pid, libc::SIGKILL) }, 0); - conn.wait_for("the sidecar's exit", |v| { - v["type"] == "freshAgent.event" - && v["event"]["type"] == "freshAgent.error" - && v["event"]["code"] == "SIDECAR_EXITED" - }) - .await; -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_freshclaude_and_a_kilroy_create_hand_the_cli_the_freshell_mcp_for_their_pane() { let (rig, _) = rig_with(resolved(), false).await; @@ -222,7 +136,7 @@ async fn a_freshclaude_and_a_kilroy_create_hand_the_cli_the_freshell_mcp_for_the .await; create_for_pane(&mut conn, "create-kilroy", "kilroy", ("tab-2", "pane-2")).await; - let options = query_options(&rig); + let options = rig.query_options(); assert_eq!(options.len(), 2, "two sidecars booted: {options:?}"); assert_mcp_for_pane( &rig, @@ -255,7 +169,7 @@ async fn the_setting_applies_to_the_next_launch_without_a_restart() { ) .await; - let options = query_options(&rig); + let options = rig.query_options(); assert_eq!(options.len(), 3, "three sidecars booted: {options:?}"); assert!( options[0].get("mcpServers").is_none(), @@ -287,7 +201,7 @@ async fn a_hosted_lane_keeps_its_fixed_recipe_and_never_gets_the_ordinary_one() ) .await; - let options = query_options(&rig); + let options = rig.query_options(); assert_eq!( options[0]["mcpServers"], json!({ "freshell": { "command": "node", "args": ["/opt/freshell-mcp/server.js"] } }), @@ -302,8 +216,10 @@ async fn an_attach_resume_hands_the_cli_the_freshell_mcp_for_the_attaching_pane( let mut conn = rig.connect().await; let session_id = create_for_pane(&mut conn, "create-1", "freshclaude", ("tab-1", "pane-1")).await; - let durable = converse(&rig, &mut conn, &session_id, &["first prompt"]).await; - kill_the_sidecar(&rig, &mut conn).await; + let durable = rig + .converse(&mut conn, &session_id, &["first prompt"]) + .await; + rig.kill_first_sidecar(&mut conn).await; // The pane, moved to another tab meanwhile, attaches again (a reload). conn.send(json!({ @@ -316,7 +232,9 @@ async fn an_attach_resume_hands_the_cli_the_freshell_mcp_for_the_attaching_pane( "paneId": "pane-1", })) .await; - let resumed = wait_for_query(&rig, "the attach-resume", |o| o["resume"] == durable).await; + let resumed = rig + .wait_for_query("the attach-resume", |o| o["resume"] == durable) + .await; assert_mcp_for_pane(&rig, &resumed, Some(("tab-9", "pane-1")), "attach-resume"); } @@ -328,8 +246,10 @@ async fn a_handoff_resume_runs_for_the_adopting_pane(session_type: &str) { let mut conn = rig.connect().await; let session_id = create_for_pane(&mut conn, "create-1", session_type, ("tab-1", "pane-1")).await; - let durable = converse(&rig, &mut conn, &session_id, &["first prompt"]).await; - kill_the_sidecar(&rig, &mut conn).await; + let durable = rig + .converse(&mut conn, &session_id, &["first prompt"]) + .await; + rig.kill_first_sidecar(&mut conn).await; let (status, answer) = rig .post_json( @@ -346,7 +266,9 @@ async fn a_handoff_resume_runs_for_the_adopting_pane(session_type: &str) { ) .await; assert_eq!(status, 200, "the handoff resumes the session: {answer}"); - let resumed = wait_for_query(&rig, "the handoff resume", |o| o["resume"] == durable).await; + let resumed = rig + .wait_for_query("the handoff resume", |o| o["resume"] == durable) + .await; assert_mcp_for_pane( &rig, &resumed, @@ -371,13 +293,9 @@ async fn an_undo_respawn_runs_for_the_same_pane() { let mut conn = rig.connect().await; let session_id = create_for_pane(&mut conn, "create-1", "freshclaude", ("tab-1", "pane-1")).await; - let durable = converse( - &rig, - &mut conn, - &session_id, - &["first prompt", "second prompt"], - ) - .await; + let durable = rig + .converse(&mut conn, &session_id, &["first prompt", "second prompt"]) + .await; let (epoch, generation) = owner_pair(&mut conn, &durable).await; conn.send(json!({ @@ -397,10 +315,11 @@ async fn an_undo_respawn_runs_for_the_same_pane() { }) .await; assert_eq!(ack["event"]["type"], "freshAgent.rolledBack", "{ack}"); - let undone = wait_for_query(&rig, "the undo's respawn", |o| { - o["forkSession"] == true && o["resume"] == durable - }) - .await; + let undone = rig + .wait_for_query("the undo's respawn", |o| { + o["forkSession"] == true && o["resume"] == durable + }) + .await; assert_mcp_for_pane(&rig, &undone, Some(("tab-1", "pane-1")), "undo respawn"); } @@ -410,7 +329,9 @@ async fn a_fork_child_runs_for_the_forking_pane() { let mut conn = rig.connect().await; let session_id = create_for_pane(&mut conn, "create-1", "freshclaude", ("tab-1", "pane-1")).await; - let durable = converse(&rig, &mut conn, &session_id, &["first prompt"]).await; + let durable = rig + .converse(&mut conn, &session_id, &["first prompt"]) + .await; let (epoch, generation) = owner_pair(&mut conn, &durable).await; conn.send(json!({ @@ -436,6 +357,8 @@ async fn a_fork_child_runs_for_the_forking_pane() { let child = forked["sessionId"] .as_str() .expect("forked names the child"); - let options = wait_for_query(&rig, "the fork's child", |o| o["sessionId"] == child).await; + let options = rig + .wait_for_query("the fork's child", |o| o["sessionId"] == child) + .await; assert_mcp_for_pane(&rig, &options, Some(("tab-1", "pane-1")), "fork child"); } From ab3cd3ba44de582fd4bab4194a8dd9fd3f348349 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:29:22 -0700 Subject: [PATCH 21/21] test(e2e): the agent finds its own tab through the Freshell MCP and splits its own pane The own-tab check was dropped after a cloud run where the server's tab list lacked the browser's tab. The cause is timing, not the MCP wiring: the browser mirrors its layout to the server after a debounce that every layout change restarts, and the simulated turn ran within that window, so the server knew only the tab the test created through REST. The spec now waits until the server knows the browser's tabs, then checks that the agent's list-tabs reply includes its own tab. It also covers the default target: while the model thinks, the user switches to another tab, and the agent's untargeted split-pane must split its own pane in its own tab and leave the active tab alone. Ignoring the pane context in the MCP tool's default target makes this fail. Co-Authored-By: Claude Opus 5.5 --- .../freshclaude-freshell-mcp-rust.spec.ts | 94 ++++++++++++++++--- 1 file changed, 81 insertions(+), 13 deletions(-) diff --git a/test/e2e-browser/specs/freshclaude-freshell-mcp-rust.spec.ts b/test/e2e-browser/specs/freshclaude-freshell-mcp-rust.spec.ts index 046be40e8..1058d8a31 100644 --- a/test/e2e-browser/specs/freshclaude-freshell-mcp-rust.spec.ts +++ b/test/e2e-browser/specs/freshclaude-freshell-mcp-rust.spec.ts @@ -8,9 +8,12 @@ // CLI does (conformance scenario `mcp`), and calls its tools for the model. // // 1. the agent calls the Freshell MCP `list-tabs` and its reply lists the -// open tabs: the command Rust resolved, the endpoint and the token in -// the sidecar's environment all work against the real server; -// 2. with Settings → Advanced → "Agent MCP server" turned off, a new pane's +// open tabs, its own among them: the command Rust resolved, the endpoint +// and the token in the sidecar's environment all work against the real +// server; +// 2. an untargeted `split-pane` splits the agent's own pane, even while the +// user is looking at another tab (the pane context the CLI got); +// 3. with Settings → Advanced → "Agent MCP server" turned off, a new pane's // agent has no Freshell MCP server: the same call is "No such tool". // // CLOUD-LEGALITY: no CLI binary and no network (the simulator stands in for @@ -24,15 +27,28 @@ import { ensureMcpServerBuilt, REPO_ROOT } from '../helpers/mcp-stdio-client.js' const PROMPT = 'which tabs are open?' const REPLY_PREFIX = 'Open tabs: ' +const SPLIT_PROMPT = 'split your pane' +const SPLIT_PREFIX = 'Split: ' /** A tab the agent's list-tabs must find, named so its title is unmistakable. */ const PROBE_TAB = 'g19 probe tab' -/** The model calls the Freshell MCP tool's list-tabs, then repeats its answer. */ +/** + * The model calls the Freshell MCP tool's list-tabs, then repeats its answer; + * asked to split its pane, it thinks for a few seconds (time for the user to + * switch tabs), then calls split-pane with no target. + */ const program = { - rules: [onPrompt(PROMPT, [ - { kind: 'tool', data: { name: 'mcp__freshell__freshell', input: { action: 'list-tabs' } } }, - { kind: 'text', data: { text: REPLY_PREFIX, echoToolResult: true } }, - ])], + rules: [ + onPrompt(PROMPT, [ + { kind: 'tool', data: { name: 'mcp__freshell__freshell', input: { action: 'list-tabs' } } }, + { kind: 'text', data: { text: REPLY_PREFIX, echoToolResult: true } }, + ]), + onPrompt(SPLIT_PROMPT, [ + { kind: 'text', data: { chunks: 32, delayMs: 250 } }, + { kind: 'tool', data: { name: 'mcp__freshell__freshell', input: { action: 'split-pane', mode: 'shell' } } }, + { kind: 'text', data: { text: SPLIT_PREFIX, echoToolResult: true } }, + ]), + ], } async function withLane( @@ -59,13 +75,31 @@ async function api(lane: ClaudeSimLane, method: 'GET' | 'POST', route: string, b return response.json() } +/** The agent's turn in `pane` whose reply starts with `prefix`. */ +async function replyTurn(pane: ClaudeSimPane, prefix: string) { + const turn = pane.paneRoot.getByRole('article', { name: /transcript turn$/ }).filter({ hasText: prefix }) + await expect(turn).toHaveCount(1, { timeout: 30_000 }) + return turn +} + /** The agent's reply to PROMPT in `pane`, once its turn is over. */ async function reply(lane: ClaudeSimLane, pane: ClaudeSimPane) { await lane.send(pane, PROMPT) await lane.waitForIdle(pane) - const turn = pane.paneRoot.getByRole('article', { name: /transcript turn$/ }).filter({ hasText: REPLY_PREFIX }) - await expect(turn).toHaveCount(1, { timeout: 30_000 }) - return turn + return replyTurn(pane, REPLY_PREFIX) +} + +/** The server's tab list (GET /api/tabs), the list the agent's MCP server reads. */ +async function serverTabs(lane: ClaudeSimLane): Promise<{ ids: string[]; activeTabId: string | null }> { + const { data } = await api(lane, 'GET', '/api/tabs') + return { ids: data.tabs.map((tab: { id: string }) => tab.id), activeTabId: data.activeTabId ?? null } +} + +/** How many panes a pane layout holds. */ +function leafCount(node: any): number { + if (!node) return 0 + if (node.type === 'leaf') return 1 + return (node.children ?? []).reduce((sum: number, child: any) => sum + leafCount(child), 0) } /** The `query()` options the simulated CLI recorded for its sessions, in order. */ @@ -85,12 +119,21 @@ test.describe('freshclaude Freshell MCP (production sidecar over the CLI simulat test(`${flavour}: the agent lists the open tabs through the Freshell MCP server`, async ({ browser }) => { await withLane(browser, { flavour, program }, async (lane) => { const pane = await lane.openPane() - await api(lane, 'POST', '/api/tabs', { name: PROBE_TAB, mode: 'shell' }) + const probeTabId: string = (await api(lane, 'POST', '/api/tabs', { name: PROBE_TAB, mode: 'shell' })).data.tabId + // The browser mirrors its layout to the server a moment after the + // layout settles (a debounce), so a just-opened pane's tab reaches the + // server's tab list shortly after it opens. + await expect.poll(async () => (await serverTabs(lane)).ids, { + timeout: 15_000, + message: 'the server knows the browser\'s tabs', + }).toEqual(expect.arrayContaining([pane.tabId, probeTabId])) const turn = await reply(lane, pane) // The real server's own tab list (GET /api/tabs), read through the MCP - // server with the endpoint and token the server gave the sidecar. + // server with the endpoint and token the server gave the sidecar; the + // agent's own tab (the FRESHELL_TAB_ID it got) is in it. await expect(turn).toContainText('"status": "ok"') + await expect(turn).toContainText(pane.tabId) await expect(turn).toContainText(PROBE_TAB) await expect(turn).not.toContainText('No such tool') @@ -103,6 +146,31 @@ test.describe('freshclaude Freshell MCP (production sidecar over the CLI simulat FRESHELL_TAB_ID: pane.tabId, FRESHELL_PANE_ID: pane.paneId, }) + + // While the model thinks, the user switches to the probe tab. The + // untargeted split must still split the agent's own pane, not the + // pane the user is looking at. + await lane.send(pane, SPLIT_PROMPT) + await api(lane, 'POST', `/api/tabs/${encodeURIComponent(probeTabId)}/select`, {}) + await expect.poll(async () => (await serverTabs(lane)).activeTabId, { + timeout: 5_000, + message: 'the probe tab is the active tab', + }).toBe(probeTabId) + expect( + leafCount(await lane.harness.getPaneLayout(pane.tabId)), + 'the model is still thinking when the user switches tabs', + ).toBe(1) + await expect.poll(() => lane.paneStatus(pane), { timeout: 30_000, message: 'the split turn ends' }).toBe('idle') + await expect.poll(async () => leafCount(await lane.harness.getPaneLayout(pane.tabId)), { + timeout: 15_000, + message: 'the agent\'s own tab gains the new pane', + }).toBe(2) + expect(leafCount(await lane.harness.getPaneLayout(probeTabId)), 'the active tab is left alone').toBe(1) + // Back on the agent's tab, its reply shows the split succeeded. + await api(lane, 'POST', `/api/tabs/${encodeURIComponent(pane.tabId)}/select`, {}) + await expect.poll(() => lane.harness.getActiveTabId(), { timeout: 5_000 }).toBe(pane.tabId) + const splitTurn = await replyTurn(pane, SPLIT_PREFIX) + await expect(splitTurn).toContainText('"status": "ok"') }) }) }